perf(sqlite): reuse admission across workers and reopens (#167739)

* perf(sqlite): checkpoint shared admission across workers (WIP)

* test(sqlite): align worker fixtures with shared admission

* perf(sqlite): avoid repeated quarantine reads in retained scopes

* test(sqlite): align first-admission coverage and deletion fixtures

Route retained existing-state writer coverage through the host-owned worker project, preserve first-admission incompatibility refusal, and match the final deletion-authority contract. Keep the gateway test under its inherited line count by moving existing pure fixture helpers. Regenerate assertion allowances without adding production assertions.

* build(sqlite): include shared admission in compiled wrappers

Include the thirteen transitive admission modules required by the native wrapper import-closure proof. Carry the existing birthtime through the worker validation fixture to preserve its physical-file identity.

* test(sqlite): use the canonical settlement owner in publication proof

* test(sqlite): exercise worker failure through its owner

* fix(sqlite): preserve cleanup and explicit maintenance validation

* fix(sqlite): retain revocation and scoped worker admission

* fix(sqlite): preserve migration and read-cache owners

* fix(sqlite): preserve maintenance and bootstrap admission

* fix(sqlite): keep temporary trigger admission connection-local

* fix(sqlite): preserve unavailable discovery target isolation

* fix(sqlite): preserve rollback and cleanup admission boundaries

* fix(sqlite): preserve admission custody and explicit update inspection

* fix(sqlite): preserve admission custody across cleanup and creation

* fix(pr): retain receipt dependency in wrapper inventory

* fix(sqlite): preserve first-creation admission authority
This commit is contained in:
Peter Steinberger 2026-10-09 23:16:02 -07:00 • committed by GitHub
parent e7eece5bae
commit bb7d32e55d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
226 changed files with 10134 additions and 3187 deletions

View file

@ -56,7 +56,7 @@ Update instructions at their owner instead of adding competing rules here.
- State and caches use SQLite, not new JSON/JSONL/sidecar stores; files are for named user artifacts, imports/exports, attachments, logs, backups, or external-tool contracts.
- Kysely for ordinary SQLite; raw SQL only for schema, migrations, bootstrap, and justified primitives. Write transactions are synchronous: plan async work first, reread authoritative rows, then write; no Promise or `await` in transaction callbacks.
- Database access runs in worker threads (read-only worker scope, SQLite writer broker), never on the Gateway main thread, which only awaits results and installs published facts. Exceptions: boot admission, migrations, Doctor/CLI one-shots, lock primitives. Existing main-thread access is legacy: never add more; migrate what you touch.
- Schema/integrity/index checks run once at admission and in the migration owner; runtime carries admitted facts and never re-queries them, and cache freshness probes observe foreign commits as [database schemas](docs/reference/database-schemas.md) defines. Per-call checks are legacy: never add; migrate when touched.
- SQLite format, schema-version, integrity, canonical-index, and table-existence checks run once per physical database per process load, shared with all workers and handles. This includes quarantine-store format; existing indexed durable quarantine-row guards stay current so another process's confirmed corruption is observed. Reuse admitted format facts across opens, scopes, requests, and idle close; identify replacement files by volume, inode, and birthtime with `fstat`, not SQL. Migration/repair owners publish new facts after their DDL; Doctor and explicit verification retain their checks. Cached-row freshness probes still observe foreign commits as [database schemas](docs/reference/database-schemas.md) defines. Never add repeated format checks; remove them when touched.
- Privileged actions need current owner-held authority: revalidate after awaited work and right before side effects. Tokens, signatures, expiry, and matching IDs alone do not prove live authority.
- Core owns shared message tools, action vocabulary, and dispatch; channels own account, security, conversation, and transport contracts. Keep typed command/approval/URL/action distinctions until encoding; never infer commands from raw strings.
- Carry prepared facts through hot paths; reuse process-stable plugin metadata and lifecycle-owned caches; never repeatedly load registries or freshness-poll files. Preserve lazy module boundaries and verify relevant builds on the authorized host.

View file

@ -2568,7 +2568,6 @@ src/state/local-onboarding-state.ts 1
src/state/openclaw-agent-db-permissions.ts 1
src/state/openclaw-agent-db-registry-listing.ts 2
src/state/openclaw-agent-db-session-migrations.ts 1
src/state/openclaw-quarantine-store.ts 1
src/state/openclaw-state-db-delivery-queue-backfill.ts 1
src/state/openclaw-state-db-legacy-backfills.ts 4
src/state/openclaw-state-db-schema-helpers.ts 1

View file

@ -6657,7 +6657,6 @@
["src/config/sessions/session-history-worker-errors.test-support.ts","./disk-budget-runtime.js","96a65b3e512a97eba31cc8e5ed1fa56260dfff775469ff1bb329454dbaafdc39",1]
["src/config/sessions/session-history-worker-errors.test-support.ts","./session-accessor.sqlite-exact-read.js","1e84b077c2aed79b63160020fb76d506a01ac6cbe78685ea27272d4e894cae80",1]
["src/config/sessions/session-history-worker-errors.test-support.ts","./session-sharing-store.js","d93387a76c2b387d2225899320c50734ff8d78387a34173f26a4290bb794fd66",1]
["src/config/sessions/session-history-worker-errors.test-support.ts","./session-transcript-hydration.worker.js","e1b02784fd81a97e71650beaece130d3771e123f4011a105c0fef338d2e44364",1]
["src/config/sessions/session-store-target-inventory.test.ts","./targets-read-availability.js","703ea4103b77aee18350705892581a8e996f72ef21ae4d7cdb5a0606230e24cc",1]
["src/config/sessions/transcript-resolve.runtime.test.ts","./transcript.js","a9a3538ede0e4276a9cc437c63137d88daca3506765e3892a5612812147ee487",1]
["src/config/state-dir-dotenv.test.ts","../logging/subsystem.js","6b154f74290d6c84838b4a5b1e90ccd0fb1ccc3f52262c70817046790e4337ab",1]

View file

@ -445,7 +445,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/session-discussion` | External session discussion provider contracts, registration, and canonical Control UI session path building |
| `plugin-sdk/session-transcript-runtime` | Private-local after July 2026; Transcript identity, bounded raw and visible cursors, scoped target/read/write helpers, read-only native transcript catalog pages with portable sender attribution, visible message-entry projection, update publishing, write locks, and transcript memory hit keys |
| `plugin-sdk/sqlite-runtime` | Private-local after July 2026; SQLite agent-schema, path, transaction, and shared-handle borrowing helpers for first-party runtime. Type-only `Generated` and `Selectable` model generated columns and selected rows in Kysely table definitions. `compileSqliteQueryBindings` compiles fixed Kysely SQL with fresh bindings for caller-owned native statements; statement lifetime stays with the caller. `iterateSqliteQuerySync` streams Kysely query rows for incremental decoding; consume the iterator before closing its database. `sqliteStringSet` binds string membership as one SQLite JSON table-valued query parameter, preserving one query snapshot without a variable-count placeholder list. `borrowOpenClawAgentDatabase` returns `{ db, release }`; active borrows prevent cache eviction, `release()` does not close the handle, and explicit owner disposal still revokes it. `withOpenClawAgentDatabaseAsync` admits a connection asynchronously and retains its original identity through the operation’s settlement; it does not move callback execution off the caller thread. |
| `plugin-sdk/sqlite-worker-runtime` | Private-local native SQLite, Kysely query, synchronous transaction, and operation-admission primitives for worker backends and their shared helpers. `runSqliteDeferredTransactionSync` groups synchronous reads into one SQLite snapshot; its callback must not return a Promise. Imports existing owners directly so workers do not load host database lifecycle code. Host store creation and lifecycle coordination remain on `sqlite-runtime`. |
| `plugin-sdk/sqlite-worker-runtime` | Private-local native SQLite, Kysely query, synchronous transaction, and operation-admission primitives for worker backends and their shared helpers. `runSqliteDeferredTransactionSync` groups synchronous reads into one SQLite snapshot; its callback must not return a Promise. `admitSqliteSchema` admits a tracked connection to the shared physical catalog; `tableExists` consumes those facts without another catalog query. `getSqliteDatabaseAdmission`, `publishSqliteDatabaseAdmission`, and `SqliteDatabaseAdmissionKey` share owner-validated facts once per physical database per process, including worker reopen; publish after successful initialization or migration, and mark schema-dependent facts with `schemaDependent: true`. Imports existing owners directly so workers do not load host database lifecycle code. Host store creation and lifecycle coordination remain on `sqlite-runtime`. |
| `plugin-sdk/cron-store-runtime` | Private-local after July 2026; Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | Canonical config/state/OAuth path helpers. `resolveConfigPath(env?, stateDir?, homedir?)` returns the selected local config path without loading config or opening the state database. It honors `OPENCLAW_CONFIG_PATH` (including home-relative expansion), the selected state directory, and existing legacy config candidates before canonical fallback. It may inspect filesystem existence; it does not create files, validate config contents, or establish path ownership/permission authority. Use it for pre-config native bootstrap selection instead of copying path policy; callers still enforce their filesystem and lifecycle admission checks. |
| `plugin-sdk/plugin-state-runtime` | Private-local after July 2026; Plugin-scoped keyed-state and BLOB contracts plus connection pragma, verified WAL maintenance, and atomic STRICT-schema migration helpers. Plugin-state leases were removed; use SQLite transactions and keyed stores instead |

View file

@ -17,9 +17,14 @@ readers install their lock-wait timeout at connection open.
Non-mutating WAL observations reuse the loaded library's admitted capability;
they still observe current WAL frames and read freshness on each use. Quarantine
decision readers and writers set their existing lock-wait timeout at connection
open; each decision reads the current schema version and quarantine row in one
SQLite snapshot and validates any recorded file generation. WAL safety, quarantine authority, and recovery
behavior are unchanged.
open. The quarantine store's format is admitted once per physical database per
process, while existing guards retain the indexed durable quarantine-row lookup
in one SQLite snapshot and validate any recorded file generation.
A separate inspection process can admit a target before the Gateway's verifier
confirms corruption; its next guard must observe that recorded refusal even when
the target's physical identity is unchanged. Quarantine rows retain their pathname
scope, and aliases still share physical format, schema, and integrity facts.
The persisted schema, WAL safety, and recovery behavior are unchanged.
Managed writes publish committed facts before their public observers. Private
receipts distinguish explicit absence from incomplete coverage and preserve known
@ -27,18 +32,39 @@ commits independently of reply delivery. See
[committed facts and completeness](/reference/database-schemas/worker-access#committed-facts-and-completeness)
for ordering, rollback, and the writer families that still retain native guards.
Schema-version, integrity, canonical-index, and table-existence checks belong to open/admission and the migration owner after migrations; runtime paths must carry admitted schema facts with the handle, never re-query them, and use fresh `PRAGMA data_version` checks to observe foreign commits on the next unpinned read while preserving active SQLite snapshots. Existing per-call checks are legacy and must be migrated when touched.
SQLite format, schema-version, integrity, canonical-index, and
table-existence validation runs once per physical database per process load,
on its first admission. The admitted facts are shared with all workers and
handles, including later opens and reopens after idle close. File identity uses
volume, inode, and stable birthtime checked with `fstat`, not SQL. A replaced or
restored file needs its own first validation. Migration and repair owners validate
their changes and publish the new facts after successful DDL settlement; later
runtime consumers do not recheck them. Doctor and explicit verification retain
their checks, and observed corruption still revokes admission.
Shared-state and agent read-only connections reuse bounded prepared statements under their native connection lifecycle. Prepared-statement reuse alone does not retain query results. Read admission shares one freshness check within its synchronous operation; schema-fact lookups reuse the admitted handle without checking again. Write transactions refresh after acquiring `BEGIN`, before consuming those facts. Explicit fresh checks always execute, even inside another read operation. A foreign commit compares the schema and user versions before retaining or replacing schema facts, preserving active SQLite snapshots. Closing or replacing the connection clears retained statements and facts.
Shared-state and agent read-only connections reuse bounded prepared statements
under their native connection lifecycle. Prepared-statement reuse alone does not
retain query results. Read admission shares one `PRAGMA data_version` freshness
check within its synchronous operation; schema-fact lookups use the process's
admitted facts without SQL. Write transactions refresh cached row facts after
acquiring `BEGIN`. Explicit row-freshness checks still execute, even inside another
read operation. Foreign commits invalidate cached row results without querying
`schema_version`, `user_version`, or the catalog again. Active SQLite snapshots
keep their view until they end. Closing a connection clears its prepared statements
and row caches, while physical-database admission survives for the process lifetime.
Shared-state content-version checks reuse their value at the admitted connection
revision. Foreign commits, local writes, schema changes, and connection closure
invalidate reuse. Native transactions, pinned snapshots, unadmitted handles, and
dynamic authorizers continue reading the marker directly. Cold admission still
checks freshness after catalog capture before validating the supported version.
Schema versions, stored bytes, and upgrade or downgrade behavior are unchanged.
Shared-state content-version checks reuse the physical database's admitted version
facts across handles and workers. Ordinary commits, transactions, and connection
closure do not force another marker read. The migration owner publishes replacement
facts with its committed schema. Observed DDL without replacement facts expires
the content-version admission: an unchanged catalog shape does not prove that the
`config_machine_state` marker survived. The next owner lookup validates it once,
and later opens reuse the result. A historical snapshot keeps its own marker facts;
publication requires a positive match with the current committed admission. Each
caller still applies its published-version floor. Schema versions, stored bytes,
and upgrade or downgrade behavior are unchanged.
New agent readers share the initial freshness probe with schema validation;
New agent readers reuse the process's schema admission and probe row freshness;
subsequent unpinned uses still probe again. Shared-state worker reads keep admission
and query execution in the same freshness scope. Point transcript statistics, mutation clocks, pending-archive checks, and
hot/cold watermarks use a single statement's snapshot; composite reads retain
@ -50,23 +76,23 @@ lock-wait budgets, schemas, stored data, and update behavior are unchanged.
The admitted catalog includes index names and trigger definitions alongside tables.
Canonical session validation consumes these definitions without another catalog scan.
Canonical index admission shares the schema contract reader's batched metadata snapshot
First canonical index admission shares the schema contract reader's batched metadata snapshot
instead of querying each table and index separately. Shadowed PRAGMA names retain
native inspection, and authorization, drift detection, transactional repair, and
integrity checks remain unchanged.
native inspection during that validation. Authorization, initial drift detection,
transactional repair, and integrity checks remain with their existing owners.
First-use schema owners skip additive DDL only when all their tables and indexes
are present in the current facts. Foreign schema changes, local DDL, rollback, and
connection replacement invalidate those facts through the same connection owner;
missing objects still use the existing installation transaction. Unadmitted and
authorizer-controlled connections retain their native checks. Schemas, stored
bytes, and update behavior are unchanged.
are present in the published facts. Missing objects use the existing installation
transaction, which publishes the new schema facts on commit; rollback cannot
publish a schema that did not commit. Ordinary connection replacement and foreign
data commits do not repeat schema validation. Schemas, stored bytes, and update
behavior are unchanged.
Schema facts gathered within a managed read operation survive data-only transaction
settlement. The next operation still checks foreign commits. Unmanaged transaction
snapshots, and sibling schema publications observed inside an active transaction,
discard their facts when that snapshot ends.
Admitted schema facts survive data-only transaction settlement. The next operation
still checks foreign commits for cached row freshness. Transaction-local views of
schema facts end with their SQLite snapshot; the next transaction consumes the
process's published facts without repeating validation.
Progress-card writes reuse the transaction's admitted table facts. The schema owner creates the lazy table only when it is absent, so warm writes preserve schema facts for that handle and its local siblings. First use after rollback or a foreign schema change still creates missing storage through normal write admission. Stored cards, revision tombstones, schema versions, and upgrade or downgrade behavior are unchanged.
Progress-card writes reuse the transaction's admitted table facts. The schema owner creates the lazy table only when it is absent and publishes the committed facts for every handle and worker. A rolled-back installation remains absent until the next normal installation transaction. Stored cards, revision tombstones, schema versions, and upgrade or downgrade behavior are unchanged.
The agent-database execution owner retains up to four idle physical-agent executors in least-recently-used order. Borrowing an executor refreshes its independent 30-minute idle timeout; a fifth idle executor evicts the least recently used one. Configuration changes to the agent roster or storage paths stop warm retention and drain affected executors after their last borrower settles. Already-admitted work retains its original physical store; new requests resolve the current configuration. Explicit database closure and Gateway shutdown still revoke and drain the existing lifecycle resources. This changes no schema, stored bytes, or update behavior.
@ -78,20 +104,18 @@ worker transfers so alias publication revokes superseded proof while preserving
acknowledged copies. Later revocation still refuses publication. Schemas, stored
bytes, and update behavior are unchanged.
Retaining an already-open agent handle holds its lifetime without querying SQLite. Its read or transaction owner refreshes schema facts when consuming data; canonical readiness owns the freshness check before reusing its clean-store decision.
Retaining an already-open agent handle holds its lifetime without querying SQLite. Its read or transaction owner refreshes cached row facts when consuming data; schema facts come from process-wide admission. Canonical readiness owns the row-freshness check before reusing its clean-store decision.
Agent ownership metadata follows the admitted snapshot revision as well. Unchanged
reads, including reads within one transaction or pinned snapshot, reuse the handle's
metadata. Foreign commits on the next unpinned use, local mutations, rollback, and
schema changes require a new ownership read. Dynamic authorizers keep querying
the metadata. This changes no schema, stored bytes, or update behavior.
The shared-state content-version marker uses the same admitted read revision.
Unchanged reads reuse its successful result; foreign commits, local writes,
rollback, schema changes, and connection disposal invalidate reuse. Transactions
and pinned snapshots reuse the marker at their admitted revision;
authorizer-controlled reads still query it. Version validation and upgrade or
downgrade behavior are unchanged.
The agent ID and role in `schema_meta` describe the physical store's fixed schema
owner. Creation and migration validate and publish these facts for every handle
and worker; ordinary commits do not require another ownership-metadata read.
Observed DDL without owner-published replacement metadata expires that admission,
even when the recreated table has the same definition: its primary row must be
validated once by the next owner lookup. Historical metadata stays with its
snapshot until positively matched to current committed admission. External row
edits do not reassign an admitted store. Live session permissions, lease ownership,
and caller authority retain their current-row checks. Dynamic authorizers retain
native metadata reads. This changes no schema, stored bytes, or update behavior.
Registry discovery reuses successful migration checks for the admitted schema
generation. The minute retention sweep reads deletion history in a worker and
@ -118,7 +142,8 @@ synchronous domain read. ACP metadata reuses at most 128 rows per connection
under that revision; foreign commits, local writes, schema changes, and close
invalidate reuse. Transactions and pinned or authorizer-controlled reads still
query SQLite. Supplied shared-state writers reuse their selected handle and check
schema and ownership after `BEGIN`, without a duplicate pre-transaction row read.
ownership after `BEGIN`, without a duplicate pre-transaction row read or schema
validation.
Stored bytes, schemas, permissions, and update behavior are unchanged.
Exact entry and participant readers retain their last result at the admitted

View file

@ -9,6 +9,20 @@ title: "Integrity, troubleshooting, and recovery"
## Integrity checks
Admission validates SQLite format, schema, canonical indexes, and required
integrity once per physical database per process load. The quarantine store's
format follows the same rule. Existing guards retain the indexed durable
quarantine-row lookup so another process's recorded corruption is observed.
The process shares admitted format and integrity facts with all handles and
worker isolates. Later opens, read scopes, and writer reopens after idle close
do not repeat format or integrity validation SQL. File identity
uses volume, inode, and stable birthtime checked with `fstat`; a replaced or
restored file requires its own first admission. Migration and repair owners keep
their checks and publish new facts after successful DDL settlement. Doctor and
explicit verification keep their independent checks. Proven corruption still
revokes admission; current ownership and cached-row freshness are separate from
format validation.
Gateway agent inspections share a five-second foreground wait. Unfinished stores
remain unavailable while the startup admission owner completes their inspection
and session/model preparation after the listener is ready. Other agents and the
@ -187,14 +201,15 @@ unconfirmed close, read-only release, missing lease, changed file, mismatched
path, or missing matching verification. An interrupted release leaves its lease
for the next admission to diagnose.
| When | Check |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Every open | Validate the `schema_meta` table and primary metadata row |
| Writable agent open and Gateway readiness | Run full integrity and foreign-key checks without reusable proof, except native WAL admission with no receipt or proven same-boot process death |
| Same-process agent reopen | Reuse current file-bound runtime proof without another integrity or quick check; recheck owner, version, schema, and canonical indexes |
| Clean same-version agent restart | Recheck owner, version, schema, and canonical indexes; queue a child-process `quick_check` and foreign-key check after the Gateway is listening |
| Before a pending migration | Run a full integrity, foreign-key, role, schema, and index scan |
| Doctor, backup verification, and compaction | Run the full scan before accepting or rewriting the database |
| When | Check |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| First physical-database admission in a process | Validate format, schema metadata, and canonical indexes; share the admitted facts with all handles and workers |
| First writable agent admission and Gateway readiness | Run required integrity and foreign-key checks without reusable proof; native WAL admission with no receipt or proven same-boot process death defers that scan |
| Same-process reopen or new worker | Reuse file-bound admitted facts without schema, version, catalog, or integrity SQL; retain current ownership and durable quarantine-row checks |
| Clean same-version agent restart | Recheck owner, version, schema, and canonical indexes; queue a child-process `quick_check` and foreign-key check after the Gateway is listening |
| Before a pending migration | Run a full integrity, foreign-key, role, schema, and index scan |
| After a migration or repair | The migration or repair owner validates its changes and publishes committed facts; later consumers do not repeat the checks |
| Doctor, backup verification, and compaction | Run the full scan before accepting or rewriting the database |
The existing quarantine store keeps a reconstructible `agent_integrity_verifications`
record: canonical database path, device, inode, OpenClaw version, verification time, and
@ -216,8 +231,8 @@ including stale admission and unsettled Worker cleanup. A successful native clos
with a reader-blocked checkpoint keeps verification dirty and preserves live
runtime proof. A later last writer can certify that verification after a completed
checkpoint and native close; failed close or uncertain storage errors revoke both.
Cold opens and restarts
still require matching clean-close metadata or the admission gate.
The first open in a new process still requires matching clean-close metadata or
the admission gate. Idle close within the same process preserves admitted facts.
Cleanup workers and native agent execution workers borrow that proof under their
existing writer admission. Cleanup workers return new verification to the Gateway
after they finish.
@ -225,7 +240,7 @@ Reclamation retains one Worker connection per database, so alternating agents
reuse their admitted handles. Requests still share the archive FIFO. Each Worker
retires after 30 idle minutes, on database close, or when idle under critical
memory pressure; failed cleanup retains its original lease until settlement.
Integrity revocation, schema checks, and update behavior are unchanged.
Integrity revocation and update behavior are unchanged.
During a one-way Gateway shutdown drain, idle native execution and retained
reclamation connections close immediately. Active executions close when their
final borrower releases them; active reclamation requests settle before closing.
@ -307,11 +322,12 @@ above, or override explicit process-local revocation. Older readers can
ignore the nullable column; backup and rollback retain its existing row lifetime.
Database replacement, quarantine, and failed admission discard applicable
verification. Doctor maintenance discards remembered runtime verification after
verification. Ordinary native close does not. Doctor maintenance discards remembered runtime verification after
draining agent connections and before raw maintenance can run. Pending migrations
still run full checks, and canonical index
repairs verify their result before committing. Schema, ownership, and current
write authority are never borrowed from the integrity result.
repairs verify their result before committing, then publish the new admitted
facts. Ownership and current write authority are never borrowed from the
integrity result.
Shared-state runtime opens and automatic startup preparation converge supported
schema additions and preserve atomic upgrades from older schema versions. A

View file

@ -51,19 +51,33 @@ delete reactions for removed message identities in the same transaction, while
a reset makes old-instance rows inert. Downgrade leaves the table intact and disables Control UI reactions until
a supporting build returns. No transcript backfill or rewrite is required.
Admitted agent and cached shared-state handles retain their schema version and
table facts. The handle owner revokes these facts after local DDL or transaction
rollback. A fresh `PRAGMA data_version` check observes foreign commits on the next
unpinned read, even within the same event-loop turn. On a foreign commit, the owner
compares `schema_version` and `user_version` in one pinned snapshot and retains
facts and their revision when both are unchanged. Data-only commits therefore
avoid table and column scans while version-only changes still trigger refusal
when the stored version is newer than the running build. Actual
SQLite read snapshots retain their view until they end; the next read then observes
committed changes. Canonical session validation uses the same schema revision.
Unchanged versions reuse parsed schema facts and prepared statements without
repeating schema scans. Migration and snapshot consistency checks remain fresh reads.
This changes no stored schema, migration, durability, or update behavior.
The first admission validates each physical database's schema version and shape
once per process load. Agent, shared-state, and other SQLite owners share those
admitted facts with every later handle and worker, including reopens after idle
close. A replaced file needs first admission for its new physical identity.
Migration and repair owners validate their changes and publish new facts after
successful DDL settlement; rollback cannot publish an uncommitted schema.
The `schema_meta` role and agent ID describe the physical store's fixed schema
owner. Creation and migration validate and publish them; changing that row through
external SQL does not reassign an admitted store to another agent. Session
permissions, leases, and other live authority remain current-row decisions.
Catalog shape equality cannot prove that the `schema_meta` primary row or the
`config_machine_state` content-version marker survived DDL. Observed DDL without
owner-published replacement facts expires these row-backed admissions. The next
owner lookup validates them once; later handles and opens reuse the result.
Historical metadata remains private to its snapshot unless the owner positively
matches it to the current committed admission. Rollback restores both staged
publication and connection-local version facts.
A fresh `PRAGMA data_version` check still observes foreign commits on the next
unpinned read, even within the same event-loop turn. It invalidates cached row
facts without rereading `schema_version`, `user_version`, or the catalog. SQLite
read snapshots retain their view until they end; the next read then observes
committed rows. Initial admission still refuses unsupported versions. Doctor,
explicit verification, migration, and snapshot consistency checks retain their
own contracts. This changes no stored schema, migration, durability, or update
behavior.
The nullable requester-authority columns on GitHub publication lifecycle and
repository receipts require [state schema 18](/reference/database-schemas/state-schema-history#state-schema-18).

View file

@ -26,6 +26,67 @@ paths are migration debt, not a pattern to extend. The
[migration inventory](/reference/database-schemas/worker-access-inventory) separates
candidate main-thread paths from SQL already executing in workers.
SQLite format, schema-version, integrity, canonical-index, and
table-existence validation runs once per physical database per process load,
shared with all workers. The first opener publishes admitted facts through the
existing worker bootstrap and broker channel. Every later handle, worker isolate,
scope, and request reuses those facts, including a writer reopened after idle
close. Identity is the file's volume, inode, and stable birthtime, checked with
`fstat`; path equality or SQL version probes do not establish physical identity.
A replaced or restored file requires its own first validation.
The identity owner retains a descriptor for the process lifetime and checks its
`fstat` identity against the selected path at open. On Linux, where Node may report
change time as birthtime, the existing normalized unknown-birthtime value remains
in use; retaining the original descriptor prevents that inode from being reused
for a replacement file. This descriptor holds identity custody; it is separate
from SQLite's native connection descriptor.
Quarantine guards retain their indexed durable row lookup by target pathname.
A separate inspection process can admit a file before the Gateway's verifier
confirms corruption and records quarantine. The inspecting process must observe
that new refusal at its next guard even though the file's physical identity is
unchanged. The quarantine store's schema, version, and installation are still
admitted once per physical database per process, and aliases share the target's
physical format, schema, and integrity facts. The persisted schema is unchanged.
The host owns retained identity descriptors so worker retirement cannot close them
and release a sibling SQLite connection's POSIX locks. Workers outside OpenClaw's
admission transport can consume inherited host facts; an unshared first open
retains its native validation checks. Fact-only worker channels do not authorize
file creation. Production writer stores open through the host-only broker, which
checks current request/access authority and the captured target immediately before
creating the file. Nested worker relays share admission facts but do not expose
writer-store creation through the public runtime or Plugin SDK. Joined snapshot, deletion, and maintenance owners release identity
custody after native consumers drain and before removing files. Managed restore
retains the original files and their identity custody instead of closing those
descriptors while holding an exclusive SQLite transaction. NOCOW maintenance first
proves that no other descriptor for that file remains in the process.
Migration and repair owners retain their checks and publish replacement facts
after their DDL commits. Ordinary runtime consumers do not revalidate that result.
Doctor and explicit verification keep their independent checks, and proven
corruption still revokes admission. `PRAGMA data_version` probes continue to
refresh cached row facts and preserve SQLite snapshots; a foreign commit does not
trigger another format check. Current caller authority, lease ownership, and
canonical session-row validation retain their own lifecycles.
Raw savepoints, batches containing transaction-control statements, and synchronous
native statement callbacks can retain a historical snapshot before its schema
view is known. These views may need a private catalog capture, as may readers
overlapping our own unsettled DDL. Historical metadata stays with that snapshot
unless the owner positively matches it to current committed admission; stale
adoption cannot replace the shared catalog. Matching catalog definitions alone
cannot prove that ownership or content-version rows survived unknown DDL, so their
next owner lookup validates once unless the writer published replacement facts.
Managed read scopes reuse admitted snapshot facts. These exceptions do not add
format polling to ordinary opens, worker requests, or transaction framing.
Native statement garbage collection and worker exit must settle outstanding
snapshot and mutation ownership through the existing lifecycle owners before
shared facts become reusable. Abandoned JavaScript references or an IPC disconnect
alone do not establish native settlement; this uses lifecycle completion, not a
new polling loop.
Ordinary durable entry patches use the existing agent writer by default. Reply
settings, compaction accounting, titles, voice sessions, and bundled lifecycle
producers carry source predicates through preparation into that writer. The worker
@ -455,10 +516,10 @@ and deletion-fence query remain T1. These changes introduce no schema, stored
data, retention, durability, permission, or update migration.
Gateway deleted-agent checks batch ACP metadata candidates through the shared-state
reader only for free ACP keys whose agent is absent. When a foreign commit requires
the content-version marker to be refreshed, the reader fetches that marker and the
first metadata cohort in one statement. Admission validates the marker before
consuming metadata; the existing read revision owns subsequent row reuse.
reader only for free ACP keys whose agent is absent. When initial admission needs
the content-version marker, the reader fetches it and the first metadata cohort in
one statement. Later reads reuse admitted version facts; the existing read revision
still owns ACP row freshness and reuse.
Reset rebinding uses the ACP
writer after the session entry commits, retaining that entry writer's physical
custody and accepted cleanup authority until the metadata settles. If the session
@ -552,12 +613,13 @@ independent retargets keep their foreground history-reader concurrency.
These scheduling changes require no schema, stored-data, configuration, or update
migration.
Worker read-only agent connections load existing file-bound canonical validation receipts
at admission, before a read transaction begins. Reopening a reader then validates
pending keys without repeating a complete session inventory. Copied or replaced
files still require their own validation; canonical receipts never grant integrity
proof. Writer FIFO ordering, current-authority checks, schemas, and update behavior
remain unchanged.
Worker read-only agent connections load the process's file-bound admitted schema
and integrity facts before a read transaction begins. Reopening a reader validates
pending session keys without repeating a catalog scan, schema metadata lookup,
or complete session inventory. Durable quarantine-row guards remain current.
Copied or replaced files require their own admission; canonical session-row
receipts alone never grant integrity
proof. Writer FIFO ordering and current-authority checks remain unchanged.
When a fully checked opener registers the same live database, it promotes existing
canonical proof without revoking a pending native admission. Ordinary registration,
@ -585,17 +647,17 @@ their native owner. Canonical comparison contracts return through the existing
schema-contract cache; version facts stay within their unchanged snapshot. Each
request rereads stored facts. Schemas, stored bytes, and update behavior are unchanged.
Shared-state readers reuse the content-version marker at their admitted connection
revision. Existing foreign-commit probes, local mutations, rollback, and schema
changes invalidate that fact. Transactions, pinned snapshots, unadmitted reads,
and authorizer-controlled reads still query SQLite. Each caller applies its own
published-version floor without retaining that floor in the marker cache.
Shared-state readers reuse physical-database version admission across connections
and workers, including within transactions. Ordinary commits and connection
retirement do not invalidate these facts. Migration publishes replacement facts;
an unseen historical snapshot may read its matching marker once without replacing
a newer publication. Each caller still applies its own published-version floor.
Trajectory retention readers receive already cached canonical validation
definitions under the exact source-schema key. Cold hosts do not build a
comparison database to prepare that handoff. Each reader still validates the
actual schema, database identity, quarantine state, and read freshness; retention
selection and retry policy are unchanged.
Trajectory retention readers receive already admitted schema facts under the
physical database identity. Cold hosts do not build a comparison database to
prepare that handoff. Each reader retains its physical identity and row-freshness
checks without repeating schema SQL; durable quarantine-row guards remain current.
Retention selection and retry policy are unchanged.
Session lifecycle result counts use the already admitted agent executor after
maintenance settles. These metadata reads retain physical-store FIFO and source
@ -623,29 +685,31 @@ Revocation, failed discovery, deletion, and native cleanup failure keep the exis
retirement and settlement requirements. Retained readers continue to observe native
mutations, foreign commits, and physical replacement through their original owner.
Workers may inherit completed native SQLite capability checks and canonical schema
comparison definitions through the existing worker launcher. Runtime facts remain
bound to the originating process, executable, runtime, and selected library;
comparison definitions also bind their canonical source and format. Missing or
mismatched facts use the original admission path. A SQLite writer returns its
Workers inherit completed native SQLite capability checks, canonical schema
comparison definitions, and physical-database admission facts through the existing
worker launcher. Runtime facts remain bound to the originating process,
executable, runtime, and selected library; comparison definitions also bind their
canonical source and format. Physical-database facts belong to their admitted file
identity. Missing facts request that identity's first admission. A SQLite writer returns its
completed native capability fact with an existing successful reply, so later
sibling workers can inherit it without another probe. The native runtime owner
checks the same process and library identity and current WAL safety floor before
forwarding it; invalid hints leave both write settlement and normal admission
unchanged. Each worker still validates its
actual database and current caller authority. Registry admission likewise reuses
exact canonical audit definitions from admitted schema facts, retaining structural
validation for other supported shapes. Schemas, stored bytes, retention, and update
behavior are unchanged.
unchanged. Each worker still checks physical identity and current caller authority;
it consumes published schema and integrity facts without repeating validation.
Registry admission likewise reuses admitted schema facts. Its first validation
retains structural checks for other supported shapes. Schemas, stored bytes,
retention, and update behavior are unchanged.
Completed full expected-schema descriptions are shared across bundled module
copies and inherited by newly created workers through that same launcher. The
Gateway prepares the canonical shared-state and agent descriptions during boot
admission, after SQLite runtime selection and before starting database workers.
The cache is keyed by complete canonical SQL, so same-version additive definitions
remain distinct. Only expected definitions are transferred: every admission still
inspects its actual database, and newer database versions remain refused. Retained
supervisors forward the caller's current definitions when creating each worker.
remain distinct. Actual admitted facts are shared separately by physical database
identity; the first admission still refuses newer database versions. Retained
supervisors forward both the caller's current definitions and its admitted facts
when creating each worker.
Runtime shared-state comparisons select their existing table and index view from
the full description. They do not construct another comparison database for that
subset. The full maintenance policy still validates present lazy objects, and
@ -713,8 +777,9 @@ agent-store reader and use the existing exact entry and membership predicates at
transaction and commit grants. Transport waits check lifetime and source custody
without repeating those reads. Grants never rediscover shared-state registry or
profile ownership while the placement writer holds its transaction.
Retained readers reuse admitted schema facts, recheck ownership after foreign
commits, and refuse changed schemas before re-admission.
Retained readers reuse admitted schema facts and recheck ownership after foreign
commits. Schema changes made by OpenClaw's migration or repair owner arrive through
its fact publication.
The released `getMany` and `retireSessionPlacement` methods retain synchronous
SDK adapters through the next Plugin SDK major. Native source/reset and final
@ -1954,39 +2019,33 @@ hold. Preparation starts at worker dispatch or the preceding transaction's
settlement; it includes command loading and opening the database, not broker
queue residence.
Runtime connections borrow completed integrity and foreign-key proof from the
shared-state file's process-local lifecycle generation. Native file identity,
schema version, and the owner's schema revision must still match. Schema changes
revoke the revision, including rolled-back DDL; new captures can establish fresh
proof. Observed corruption revokes borrowed proof before native cleanup;
canonical close and replacement revoke the generation. Proof is published
only after successful transaction settlement. Simultaneous first opens can each
validate while proof is pending; they never wait on a new admission lock.
Per-connection schema contracts and mutable metadata remain checked. Explicit
maintenance and copied-file verification always check integrity, with canonical
read-only admission performing one scan instead of two.
Runtime connections reuse completed schema, integrity, and foreign-key admission
from the shared-state file's process-wide record. The first opener validates the
physical database and publishes the result for sibling handles and workers;
concurrent or later opens reuse that result. Closing a native connection does not
discard it. A new physical file needs fresh admission, while observed corruption
revokes existing admission before native cleanup. A migration or repair publishes
its new schema facts only after successful transaction settlement. Mutable
metadata retains its row-freshness checks. Explicit maintenance and copied-file
verification keep their independent integrity checks.
Cold writable agent admission uses the existing agent executor before a bundled
runtime caller receives its native handle. Concurrent acquisitions share that executor's
physical generation. Its validation receipt carries the admitted schema facts;
later native handles compare committed schema markers and reuse those facts
instead of repeating canonical table, index, trigger, and integrity scans.
later native handles check physical file identity and reuse those facts without
schema-marker queries or canonical table, index, trigger, and integrity scans.
Session generation and transcript-index trackers declare their fixed connection-local
tables, indexes, and triggers to the schema owner, which checks existing TEMP names
and shapes once at installation before preserving the receipt. Mismatched objects,
other TEMP objects, and ordinary local DDL still revoke the shared schema
proof, including rolled-back DDL; a partial tracker installation also revokes it. Revoked
facts on live handles and host-handle eviction return to the retained worker for admission and
publication; stale proof never falls back to schema scans on the Gateway thread.
The worker validates changed schemas before publishing replacement facts while
retaining its native generation. Ordinary execution reuses completed preparation;
explicit host readmission and startup recovery still request the worker's current
validation. Re-adopting an unchanged schema preserves its facts identity, so
standing-intent bindings skip already-covered additive DDL. A foreign schema change
observed by the next unpinned freshness check also revokes the shared receipt;
data-only commits preserve schema facts. File replacement and revoked integrity
still require fresh admission. Mutable agent ownership is checked again on
acquisition and when each connection opens.
and shapes once at installation. Installing those declared TEMP tracking objects
does not cause another validation of the physical database. The migration or repair owner publishes
replacement facts for committed local DDL; rollback cannot publish uncommitted
facts. Host-handle eviction preserves process admission and never falls back to
schema scans on the Gateway thread. Ordinary execution reuses completed
preparation, including the facts used to skip already-covered additive DDL.
Foreign data commits refresh row facts without schema-marker queries. File
replacement requires first admission for the replacement; revoked integrity
requires recovery through its existing owner. Mutable agent ownership remains
subject to its own current-row checks.
Cloud turns retain the admitted handle through execution and finalization, so
their existing synchronous transcript-authority checks cannot become cold openers
after an idle eviction.
@ -2336,12 +2395,11 @@ later successful receipt does not clear that uncertainty. Stored formats,
schemas, and path-redaction rules are unchanged.
The canonical worker retains the actual existing-schema ledger connection for
step and phase mutations. Its first transaction admits integrity and the stable
table contract; later writes reuse the schema owner's facts and re-admit that
contract after schema changes, including another feature's lazy table creation.
Every write retains transaction-held source, ownership metadata, content-version,
and write-authority checks. Content markers remain current SQLite snapshot reads;
only their prepared statements are cached.
step and phase mutations. The first admission validates integrity and the stable
table contract; later writes and reopened connections reuse that physical store's
admitted subset. Another feature's lazy table creation does not repeat its
validation. Every write retains transaction-held physical-source and live
write-authority checks; schema ownership and content versions come from admission.
Candidate validation warms that writer before snapshot selection and holds the
existing actor operation through validation and copied-state cleanup, including
silent copy intervals. Idle inspection and actor drainage include the retained

View file

@ -1,3 +1,4 @@
import { copyFileSync, renameSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { SqliteWorkerBackend } from "openclaw/plugin-sdk/sqlite-worker-runtime";
@ -15,6 +16,7 @@ const reads = vi.hoisted(() => ({
}));
const preparations = vi.hoisted(() => new Map<string, number>());
const batchReads = vi.hoisted(() => [] as string[][]);
const schemaStatements = vi.hoisted(() => [] as string[]);
vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/sqlite-worker-runtime")>();
return {
@ -22,7 +24,22 @@ vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
openNodeSqliteDatabase: (...args: Parameters<typeof actual.openNodeSqliteDatabase>) => {
const db = actual.openNodeSqliteDatabase(...args);
const prepare = db.prepare.bind(db);
const recordSchema = (sql: string) => {
if (
/user_version|schema_version|sqlite_schema|sqlite_master|pragma_table|CREATE\s+(?:TABLE|INDEX)/i.test(
sql,
)
) {
schemaStatements.push(sql);
}
};
const exec = db.exec.bind(db);
vi.spyOn(db, "exec").mockImplementation((sql) => {
recordSchema(sql);
exec(sql);
});
vi.spyOn(db, "prepare").mockImplementation((sql) => {
recordSchema(sql);
const statement = prepare(sql);
if (/^\s*(?:insert into "(?:frames|standups)"|update "batches")/i.test(sql)) {
preparations.set(sql, (preparations.get(sql) ?? 0) + 1);
@ -97,12 +114,41 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
vi.restoreAllMocks();
preparations.clear();
batchReads.length = 0;
schemaStatements.length = 0;
cleanup();
}
}),
);
const day = "2026-07-03";
it("reuses schema admission on reopen and validates a replacement physical database", async () => {
const dataDir = tempDirs.make("logbook-schema-admission-");
const databasePath = path.join(dataDir, "logbook.sqlite");
for (let opened = 0; opened < 3; opened += 1) {
schemaStatements.length = 0;
const backend = createSqliteWorkerBackend({ dataDir }, { databasePath });
try {
if (opened === 1) {
expect(schemaStatements).toEqual([]);
} else {
expect(schemaStatements.length).toBeGreaterThan(0);
}
if (opened === 0) {
backend.execute({ type: "saveStandup", input: { day, text: "Persisted standup" } });
}
expect(backend.execute({ type: "getStandup", input: { day } })).toMatchObject({
text: "Persisted standup",
});
} finally {
await backend.close();
}
if (opened === 1) {
renameSync(databasePath, `${databasePath}.original`);
copyFileSync(`${databasePath}.original`, databasePath);
}
}
});
function openBackend() {
const dataDir = tempDirs.make("logbook-read-budget-");
const databasePath = path.join(dataDir, "logbook.sqlite");

View file

@ -1,8 +1,10 @@
import {
copyFileSync,
existsSync,
linkSync,
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
statSync,
writeFileSync,
@ -219,6 +221,9 @@ describe("LogbookStore", () => {
const batch = await store.latestBatch();
await store.close();
const databasePath = path.join(dir, "logbook.sqlite");
// Build the old format on a physical file this process has not admitted.
renameSync(databasePath, `${databasePath}.seed`);
copyFileSync(`${databasePath}.seed`, databasePath);
const database = new DatabaseSync(databasePath);
database.exec(`
DROP INDEX IF EXISTS idx_logbook_frames_captured_at;
@ -579,6 +584,9 @@ describe("LogbookStore", () => {
await store.saveStandup(DAY, "Preserved future-version fixture");
await store.close();
const databasePath = path.join(dir, "logbook.sqlite");
// The future-version file must reach its first managed admission here.
renameSync(databasePath, `${databasePath}.seed`);
copyFileSync(`${databasePath}.seed`, databasePath);
const future = new DatabaseSync(databasePath);
future.exec("PRAGMA user_version = 2");
future.close();

View file

@ -10,9 +10,12 @@ import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
getSqliteDatabaseAdmission,
openNodeSqliteDatabase,
prepareSqliteQuerySync,
publishSqliteDatabaseAdmission,
runSqliteImmediateTransactionSync,
type SqliteDatabaseAdmissionKey,
type SqliteWorkerBackend,
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
import { pickKeyframeId } from "./analyze.js";
@ -47,6 +50,11 @@ type Database = import("node:sqlite").DatabaseSync;
const LOGBOOK_SQLITE_BUSY_TIMEOUT_MS = 5_000;
const FRAME_PRUNE_BATCH_SIZE = 64;
const schemaAdmission: SqliteDatabaseAdmissionKey<true> = {
name: "logbook.schema",
schemaDependent: true,
read: (value) => (value === true ? true : undefined),
};
class LogbookDatabaseStore {
private readonly db: Database;
private readonly query;
@ -78,17 +86,20 @@ class LogbookDatabaseStore {
foreignKeys: true,
synchronous: "NORMAL",
});
const versionRow = db.prepare("PRAGMA user_version").get();
const schemaVersion = Number(versionRow?.user_version ?? 0);
if (schemaVersion > LOGBOOK_SCHEMA_VERSION) {
throw new Error(
`Logbook database uses newer schema version ${schemaVersion}; this build supports ${LOGBOOK_SCHEMA_VERSION}`,
);
}
db.exec(SCHEMA);
if (schemaVersion < LOGBOOK_SCHEMA_VERSION) {
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: dbPath });
db.exec(`PRAGMA user_version = ${LOGBOOK_SCHEMA_VERSION};`);
if (!getSqliteDatabaseAdmission(db, schemaAdmission)) {
const versionRow = db.prepare("PRAGMA user_version").get();
const schemaVersion = Number(versionRow?.user_version ?? 0);
if (schemaVersion > LOGBOOK_SCHEMA_VERSION) {
throw new Error(
`Logbook database uses newer schema version ${schemaVersion}; this build supports ${LOGBOOK_SCHEMA_VERSION}`,
);
}
db.exec(SCHEMA);
if (schemaVersion < LOGBOOK_SCHEMA_VERSION) {
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: dbPath });
db.exec(`PRAGMA user_version = ${LOGBOOK_SCHEMA_VERSION};`);
}
publishSqliteDatabaseAdmission(db, schemaAdmission, true);
}
this.db = db;
this.walMaintenance = walMaintenance;

View file

@ -11,13 +11,19 @@ import {
MEMORY_INDEX_FTS_TABLE,
rebuildMemoryChunkFts,
} from "openclaw/plugin-sdk/memory-core-host-engine-schema";
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-worker-runtime";
import {
runSqliteImmediateTransactionSync,
tableExists as admittedTableExists,
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
import { markMemoryVectorIndexClean } from "./manager-vector-rebuild-state.js";
const MEMORY_REINDEX_SCHEMA = "memory_reindex";
export const MEMORY_INDEX_STATE_ID = 1;
function tableExists(db: DatabaseSync, schema: string, tableName: string): boolean {
if (schema === "main") {
return admittedTableExists(db, tableName);
}
const row = db
.prepare(`SELECT 1 AS ok FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
.get(tableName);

View file

@ -5,12 +5,10 @@ import {
MEMORY_INDEX_FTS_TABLE,
MEMORY_INDEX_VECTOR_TABLE,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
import { loadMemorySourceFileState } from "./manager-source-state.js";
import {
memoryTableExists,
resolvePersistedMemoryVectorIndexState,
} from "./manager-vector-rebuild-state.js";
import { resolvePersistedMemoryVectorIndexState } from "./manager-vector-rebuild-state.js";
export const MEMORY_INDEX_META_KEY = "memory_index_meta_v1";
@ -37,7 +35,7 @@ export function readMemoryRetrievalIndexState(db: DatabaseSync) {
db.prepare("SELECT 1 FROM memory_index_chunks LIMIT 1").get() !== undefined;
const hasFtsContent =
!hasIndexedChunks &&
memoryTableExists(db, MEMORY_INDEX_FTS_TABLE) &&
tableExists(db, MEMORY_INDEX_FTS_TABLE) &&
db.prepare(`SELECT 1 FROM ${MEMORY_INDEX_FTS_TABLE} LIMIT 1`).get() !== undefined;
const vectorState =
meta && meta.provider !== "none"

View file

@ -6,12 +6,10 @@ import {
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
runSqliteImmediateTransactionSync,
tableExists,
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
import { createMemoryChunkWriter, type IndexedMemoryChunk } from "./manager-chunk-writer.js";
import {
markMemoryVectorRebuildRequired,
memoryTableExists,
} from "./manager-vector-rebuild-state.js";
import { markMemoryVectorRebuildRequired } from "./manager-vector-rebuild-state.js";
import { createMemoryVectorWriter } from "./manager-vector-write.js";
const MAX_VECTOR_POINT_DELETES = 32;
@ -136,7 +134,7 @@ export class MemorySourceIndexKernel {
}
private clear(pathname: string, source: MemorySource): void {
if (memoryTableExists(this.database, MEMORY_INDEX_VECTOR_TABLE)) {
if (tableExists(this.database, MEMORY_INDEX_VECTOR_TABLE)) {
if (!this.state.vector.enabled || this.state.vector.available !== true) {
markMemoryVectorRebuildRequired(this.database);
} else {

View file

@ -0,0 +1,44 @@
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { observeHostDataSql } from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import { afterEach, expect, it } from "vitest";
import { inspectMemoryIndexPresenceInWorker } from "./manager-status-presence.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
it.each(["memory_index_chunks", "chunks"])(
"reuses the admitted catalog but reads fresh %s presence on every open",
(table) => {
const filename = path.join(tempDirs.make("memory-presence-"), "memory.sqlite");
{
using db = new DatabaseSync(filename);
db.exec(`CREATE TABLE ${table} (id TEXT PRIMARY KEY)`);
}
const observed = observeHostDataSql();
const schemaQueries = () =>
observed.queries.filter((sql) =>
/sqlite_(?:schema|master)|\bPRAGMA\s+(?:user_version|schema_version)\b/iu.test(sql),
);
try {
expect(inspectMemoryIndexPresenceInWorker(filename)).toBe(false);
expect(schemaQueries().length).toBeGreaterThan(0);
{
using db = new DatabaseSync(filename);
db.exec(`INSERT INTO ${table} VALUES ('new-memory')`);
}
observed.queries.length = 0;
expect(inspectMemoryIndexPresenceInWorker(filename)).toBe(true);
expect(schemaQueries()).toEqual([]);
{
using db = new DatabaseSync(filename);
db.exec(`DELETE FROM ${table}`);
}
observed.queries.length = 0;
expect(inspectMemoryIndexPresenceInWorker(filename)).toBe(false);
expect(schemaQueries()).toEqual([]);
} finally {
observed.restore();
}
},
);

View file

@ -5,6 +5,7 @@ import {
MEMORY_INDEX_META_TABLE,
MEMORY_INDEX_SOURCES_TABLE,
} from "openclaw/plugin-sdk/memory-core-host-engine-schema";
import { admitSqliteSchema, tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
const MEMORY_INDEX_META_KEY = "memory_index_meta_v1";
@ -13,6 +14,7 @@ export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolea
let db: DatabaseSync | undefined;
try {
db = openNodeSqliteDatabase(databasePath, { readOnly: true });
admitSqliteSchema(db);
const builtInMemoryTableSets = [
{
meta: MEMORY_INDEX_META_TABLE,
@ -21,23 +23,9 @@ export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolea
},
{ meta: "meta", sources: "files", chunks: "chunks" },
] as const;
const builtInMemoryTables = builtInMemoryTableSets.flatMap(({ meta, sources, chunks }) => [
meta,
sources,
chunks,
]);
const tableNames = new Set(
db
.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${builtInMemoryTables.map(() => "?").join(", ")})`,
)
.all(...builtInMemoryTables)
.map((row) => row.name)
.filter((name): name is string => typeof name === "string"),
);
for (const tables of builtInMemoryTableSets) {
if (
tableNames.has(tables.meta) &&
tableExists(db, tables.meta) &&
db
.prepare(`SELECT 1 AS ok FROM ${tables.meta} WHERE key = ? LIMIT 1`)
.get(MEMORY_INDEX_META_KEY)
@ -46,7 +34,7 @@ export function inspectMemoryIndexPresenceInWorker(databasePath: string): boolea
}
for (const tableName of [tables.sources, tables.chunks]) {
if (
tableNames.has(tableName) &&
tableExists(db, tableName) &&
db.prepare(`SELECT 1 AS ok FROM ${tableName} LIMIT 1`).get()
) {
return true;

View file

@ -15,6 +15,7 @@ import {
type MemorySyncParams,
type MemorySyncProgressUpdate,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
import type { MemoryCoreAcquireLocalService } from "./embedding-local-service.js";
import {
resolveEmbeddingProviderIndexIdentity,
@ -41,7 +42,7 @@ import {
readMemoryIndexMetadata,
} from "./manager-retrieval-read.js";
import { MemorySyncOutcomeLedger } from "./manager-sync-outcome.js";
import { memoryTableExists, requiresMemoryVectorRebuild } from "./manager-vector-rebuild-state.js";
import { requiresMemoryVectorRebuild } from "./manager-vector-rebuild-state.js";
import type { MemoryCoreRuntimeHost } from "./runtime-host.js";
import { buildMemorySourceFilter } from "./source-filter.js";
@ -547,7 +548,7 @@ export abstract class MemoryManagerSyncBase extends MemoryManagerDatabaseContext
}
private ensureVectorTable(dimensions: number): void {
if (this.vector.dims === dimensions && memoryTableExists(this.db, VECTOR_TABLE)) {
if (this.vector.dims === dimensions && tableExists(this.db, VECTOR_TABLE)) {
return;
}
if (!this.dropVectorTable()) {
@ -566,7 +567,7 @@ export abstract class MemoryManagerSyncBase extends MemoryManagerDatabaseContext
tableName: typeof VECTOR_TABLE | typeof LEGACY_VECTOR_TABLE = VECTOR_TABLE,
): boolean {
const legacy = tableName === LEGACY_VECTOR_TABLE;
if (legacy && !memoryTableExists(this.db, tableName)) {
if (legacy && !tableExists(this.db, tableName)) {
return false;
}
try {

View file

@ -1,5 +1,10 @@
import { DatabaseSync } from "node:sqlite";
import type { DatabaseSync } from "node:sqlite";
import { MEMORY_INDEX_META_TABLE } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { observeHostDataSql } from "openclaw/plugin-sdk/sqlite-runtime-testing";
import {
admitSqliteSchema,
openNodeSqliteDatabase,
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolvePersistedMemoryVectorIndexState } from "./manager-vector-rebuild-state.js";
@ -7,11 +12,12 @@ describe("persisted memory vector index state", () => {
let db: DatabaseSync;
beforeEach(() => {
db = new DatabaseSync(":memory:");
db = openNodeSqliteDatabase(":memory:");
db.exec(`
CREATE TABLE ${MEMORY_INDEX_META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE memory_index_chunks_vec (id TEXT PRIMARY KEY);
`);
admitSqliteSchema(db);
});
afterEach(() => db.close());
@ -21,13 +27,23 @@ describe("persisted memory vector index state", () => {
"memory_vector_rebuild_v1",
);
expect(
resolvePersistedMemoryVectorIndexState({
db,
vectorTable: "memory_index_chunks_vec",
hasSemanticChunks: true,
}),
).toEqual({ state: "complete" });
const observed = observeHostDataSql();
try {
const read = () =>
resolvePersistedMemoryVectorIndexState({
db,
vectorTable: "memory_index_chunks_vec",
hasSemanticChunks: true,
});
expect(read()).toEqual({ state: "complete" });
db.prepare(`DELETE FROM ${MEMORY_INDEX_META_TABLE} WHERE key = ?`).run(
"memory_vector_rebuild_v1",
);
expect(read()).toEqual({ state: "incomplete" });
expect(observed.queries.filter((sql) => /sqlite_(?:schema|master)/iu.test(sql))).toEqual([]);
} finally {
observed.restore();
}
});
it("keeps a pre-marker vector index unverified", () => {

View file

@ -2,15 +2,10 @@
import type { DatabaseSync } from "node:sqlite";
import { MEMORY_INDEX_META_TABLE } from "openclaw/plugin-sdk/memory-core-host-engine-schema";
import type { MemoryVectorIndexState } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { tableExists } from "openclaw/plugin-sdk/sqlite-worker-runtime";
const VECTOR_REBUILD_META_KEY = "memory_vector_rebuild_v1";
export function memoryTableExists(db: DatabaseSync, tableName: string): boolean {
return Boolean(
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName),
);
}
export function markMemoryVectorIndexClean(db: DatabaseSync): void {
db.prepare(
`INSERT INTO ${MEMORY_INDEX_META_TABLE} (key, value) VALUES (?, 'clean')
@ -47,7 +42,7 @@ export function resolvePersistedMemoryVectorIndexState(params: {
if (row?.value === "1") {
return { state: "incomplete" };
}
if (!memoryTableExists(params.db, params.vectorTable)) {
if (!tableExists(params.db, params.vectorTable)) {
return params.metaVectorDims && params.hasSemanticChunks
? { state: "incomplete" }
: { state: "empty" };

View file

@ -9,6 +9,51 @@ import { createSqliteWorkerBackend } from "./store.worker.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => vi.restoreAllMocks());
it("reopens with admitted persistent schema while creating fresh connection scratch", () => {
const directory = tempDirs.make("team-reports-schema-admission-");
const databasePath = path.join(directory, "reports.sqlite");
const open = sqliteRuntime.openNodeSqliteDatabase;
const schemaStatements: string[] = [];
vi.spyOn(sqliteRuntime, "openNodeSqliteDatabase").mockImplementation((...args) => {
const db = open(...args);
const recordSchema = (sql: string) => {
if (
/user_version|schema_version|sqlite_schema|sqlite_master|pragma_table|team_reports_schema_migrations|CREATE\s+(?:TABLE|INDEX)/i.test(
sql,
)
) {
schemaStatements.push(sql);
}
};
const prepare = db.prepare.bind(db);
vi.spyOn(db, "prepare").mockImplementation((sql) => {
recordSchema(sql);
return prepare(sql);
});
const exec = db.exec.bind(db);
vi.spyOn(db, "exec").mockImplementation((sql) => {
recordSchema(sql);
exec(sql);
});
return db;
});
for (let opened = 0; opened < 2; opened += 1) {
schemaStatements.length = 0;
const backend = createSqliteWorkerBackend(undefined, { databasePath });
try {
if (opened === 0) {
expect(schemaStatements.length).toBeGreaterThan(0);
} else {
expect(schemaStatements).toEqual([]);
}
expect(backend.execute({ type: "listPeriods", input: {} })).toEqual([]);
backend.execute({ type: "resetActivity", input: undefined });
} finally {
backend.close();
}
}
});
it("aggregates across payload pages without paging metadata already retained for sorting", () => {
const directory = tempDirs.make("team-reports-read-budget-");
const backend = createSqliteWorkerBackend(undefined, {

View file

@ -10,10 +10,13 @@ import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
getSqliteDatabaseAdmission,
iterateSqliteQuerySync,
openNodeSqliteDatabase,
publishSqliteDatabaseAdmission,
runSqliteDeferredTransactionSync,
runSqliteImmediateTransactionSync,
type SqliteDatabaseAdmissionKey,
type SqliteWorkerCommand,
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
import { aggregateDay, aggregateDays, itemKey } from "./aggregate.js";
@ -37,6 +40,11 @@ import type { DiscordMessage, GithubItem, Period, ReportDocument } from "./types
// Bound each 12-column person-day insert to 768 parameters.
const PERSON_DAY_INSERT_BATCH_SIZE = 64;
const schemaAdmission: SqliteDatabaseAdmissionKey<true> = {
name: "teamReports.schema",
schemaDependent: true,
read: (value) => (value === true ? true : undefined),
};
type PeriodRow = {
period: Period;
@ -606,28 +614,36 @@ function openTeamReportsDatabase(dbPath: string): TeamReportsDatabase {
foreignKeys: true,
synchronous: "NORMAL",
});
db.exec(TEAM_REPORTS_SCHEMA_SQL);
const query = getNodeSqliteKysely<ReportsDatabase>(db);
const migration = executeSqliteQueryTakeFirstSync(
db,
query.selectFrom("team_reports_schema_migrations").select("id").where("id", "=", "schema-1"),
);
if (!migration) {
migrateSqliteSchemaToStrict(db, TEAM_REPORTS_SCHEMA_SQL, {
databaseLabel: "team-reports database",
});
executeSqliteQuerySync(
if (!getSqliteDatabaseAdmission(db, schemaAdmission)) {
db.exec(TEAM_REPORTS_SCHEMA_SQL);
const query = getNodeSqliteKysely<ReportsDatabase>(db);
const migration = executeSqliteQueryTakeFirstSync(
db,
query
.insertInto("team_reports_schema_migrations")
.values({ id: "schema-1", applied_at: Date.now() })
.onConflict((conflict) => conflict.column("id").doNothing()),
.selectFrom("team_reports_schema_migrations")
.select("id")
.where("id", "=", "schema-1"),
);
if (!migration) {
migrateSqliteSchemaToStrict(db, TEAM_REPORTS_SCHEMA_SQL, {
databaseLabel: "team-reports database",
});
executeSqliteQuerySync(
db,
query
.insertInto("team_reports_schema_migrations")
.values({ id: "schema-1", applied_at: Date.now() })
.onConflict((conflict) => conflict.column("id").doNothing()),
);
}
}
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`]) {
chmodIfExists(file);
}
return new TeamReportsDatabase(db, maintenance);
const database = new TeamReportsDatabase(db, maintenance);
// This connection's TEMP activity table leaves the admitted persistent schema unchanged.
publishSqliteDatabaseAdmission(db, schemaAdmission, true);
return database;
} catch (error) {
try {
maintenance?.close();

View file

@ -23,6 +23,7 @@ const sqliteStatements = vi.hoisted(() => ({
count: 0,
prepared: [] as string[],
executed: [] as string[],
schema: [] as string[],
}));
vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
@ -32,7 +33,22 @@ vi.mock("openclaw/plugin-sdk/sqlite-worker-runtime", async (importOriginal) => {
openNodeSqliteDatabase: (...args: Parameters<typeof actual.openNodeSqliteDatabase>) => {
const db = actual.openNodeSqliteDatabase(...args);
const prepare = db.prepare.bind(db);
const recordSchema = (sql: string) => {
if (
/user_version|schema_version|sqlite_schema|sqlite_master|pragma_table|PRAGMA\s+table_info|workboard_schema_migrations|CREATE\s+(?:TABLE|INDEX)/i.test(
sql,
)
) {
sqliteStatements.schema.push(sql);
}
};
const exec = db.exec.bind(db);
vi.spyOn(db, "exec").mockImplementation((sql) => {
recordSchema(sql);
exec(sql);
});
vi.spyOn(db, "prepare").mockImplementation((sql) => {
recordSchema(sql);
sqliteStatements.prepared.push(sql);
const statement = prepare(sql);
for (const method of ["all", "get", "iterate", "run"] as const) {
@ -126,6 +142,24 @@ function withStores<T>(run: (dbPath: string) => Promise<T>): Promise<T> {
}
describe("workboard sqlite batch card read", () => {
it("reopens without repeating admitted schema installation or column checks", async () => {
await withStores(async (dbPath) => {
for (let opened = 0; opened < 2; opened += 1) {
sqliteStatements.schema.length = 0;
const stores = createKernelStores(dbPath);
try {
if (opened === 0) {
expect(sqliteStatements.schema.length).toBeGreaterThan(0);
} else {
expect(sqliteStatements.schema).toEqual([]);
}
expect(await stores.cards.entries()).toEqual([]);
} finally {
await stores.close();
}
}
});
});
it.each(["worker log", "proof"])("hydrates once when adding a %s", async (operation) => {
await withStores(async (dbPath) => {
const stores = createKernelStores(dbPath);

View file

@ -5,11 +5,21 @@ import {
configureSqliteConnectionPragmas,
migrateSqliteSchemaToStrict,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-worker-runtime";
import {
getSqliteDatabaseAdmission,
openNodeSqliteDatabase,
publishSqliteDatabaseAdmission,
type SqliteDatabaseAdmissionKey,
} from "openclaw/plugin-sdk/sqlite-worker-runtime";
const SCHEMA_VERSION = 3;
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
const schemaAdmission: SqliteDatabaseAdmissionKey<true> = {
name: "workboard.schema",
schemaDependent: true,
read: (value) => (value === true ? true : undefined),
};
function tableColumns(db: DatabaseSync, tableName: string): Set<string> {
return new Set(
@ -273,6 +283,9 @@ const WORKBOARD_SCHEMA_SQL = `
`;
function ensureWorkboardSchema(db: DatabaseSync): void {
if (getSqliteDatabaseAdmission(db, schemaAdmission)) {
return;
}
db.exec(WORKBOARD_SCHEMA_SQL);
ensureColumn(db, "workboard_boards", "automation_job_id", "automation_job_id TEXT");
ensureColumn(db, "workboard_boards", "kind", "kind TEXT");
@ -289,6 +302,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
).run(migrationId, Date.now());
}
publishSqliteDatabaseAdmission(db, schemaAdmission, true);
}
function chmodIfExists(targetPath: string, mode: number): void {

View file

@ -0,0 +1,164 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { resolveRuntimeWorkerUrl } from "openclaw/plugin-sdk/process-runtime";
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it } from "vitest";
import { workboardSqliteBackendEntrypoint } from "./sqlite-backend-entrypoint.test-support.js";
import { createWorkboardSqliteStores } from "./sqlite-store.js";
const workerModuleUrl = resolveRuntimeWorkerUrl(workboardSqliteBackendEntrypoint);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const WORKBOARD_CARD_CHILD_INDEXES = [
["workboard_card_events", "workboard_card_events_card_idx"],
["workboard_card_attempts", "workboard_card_attempts_card_idx"],
["workboard_card_comments", "workboard_card_comments_card_idx"],
["workboard_card_links", "workboard_card_links_card_idx"],
["workboard_card_proof", "workboard_card_proof_card_idx"],
["workboard_card_artifacts", "workboard_card_artifacts_card_idx"],
["workboard_card_notifications", "workboard_card_notifications_card_idx"],
["workboard_worker_logs", "workboard_worker_logs_card_idx"],
] as const;
function explainWorkboardQueryPlan(
db: DatabaseSync,
sql: string,
params: readonly (number | string | null)[] = [],
): string {
const rows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{
detail?: unknown;
}>;
return rows
.map((row) => (typeof row.detail === "string" ? row.detail : JSON.stringify(row.detail ?? "")))
.join("\n");
}
describe("Workboard SQLite schema admission", () => {
it("restores dropped card child indexes without changing the schema version", async () => {
const dir = tempDirs.make("openclaw-workboard-index-reopen-");
const dbPath = path.join(dir, "workboard.sqlite");
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
await initialized.ready;
await initialized.close();
// Build the old format on a physical file this process has not admitted.
fs.renameSync(dbPath, `${dbPath}.seed`);
fs.copyFileSync(`${dbPath}.seed`, dbPath);
const db = new DatabaseSync(dbPath);
let initialMigrationIds: Array<{ id: string }>;
try {
initialMigrationIds = db
.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id")
.all() as Array<{ id: string }>;
for (const [, index] of WORKBOARD_CARD_CHILD_INDEXES) {
db.exec(`DROP INDEX ${index}`);
}
} finally {
db.close();
}
const reopened = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
await reopened.ready;
await reopened.close();
const verified = new DatabaseSync(dbPath, { readOnly: true });
try {
const indexes = new Set(
(
verified.prepare("SELECT name FROM sqlite_schema WHERE type = 'index'").all() as Array<{
name: string;
}>
).map((row) => row.name),
);
for (const [table, index] of WORKBOARD_CARD_CHILD_INDEXES) {
expect(indexes).toContain(index);
const plan = explainWorkboardQueryPlan(
verified,
`SELECT * FROM ${table} WHERE card_id = ? ORDER BY ordinal ASC`,
["card-1"],
);
expect(plan).toContain(index);
expect(plan).not.toContain("USE TEMP B-TREE FOR ORDER BY");
}
expect(
verified.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id").all(),
).toEqual(initialMigrationIds);
} finally {
verified.close();
}
});
it("migrates a version 2 workboard table to STRICT without losing rows", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-strict-migration-"));
const dbPath = path.join(dir, "workboard.sqlite");
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
await initialized.ready;
await initialized.close();
// Build the old format on a physical file this process has not admitted.
fs.renameSync(dbPath, `${dbPath}.seed`);
fs.copyFileSync(`${dbPath}.seed`, dbPath);
const legacy = new DatabaseSync(dbPath);
try {
legacy.exec(`
INSERT INTO workboard_boards (
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
) VALUES ('legacy', 'Legacy board', NULL, NULL, NULL, NULL, NULL, 1, 2, NULL);
ALTER TABLE workboard_boards RENAME TO workboard_boards_strict;
CREATE TABLE workboard_boards (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
icon TEXT,
color TEXT,
default_workspace_json TEXT,
orchestration_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER
);
INSERT INTO workboard_boards (
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
) SELECT
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
FROM workboard_boards_strict;
DROP TABLE workboard_boards_strict;
DELETE FROM workboard_schema_migrations WHERE id = 'schema-3';
INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at)
VALUES ('schema-2', 1);
`);
} finally {
legacy.close();
}
try {
const migratedStores = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
try {
await expect(migratedStores.boards.lookup("legacy")).resolves.toMatchObject({
board: { id: "legacy", name: "Legacy board" },
});
} finally {
await migratedStores.close();
}
const migrated = new DatabaseSync(dbPath, { readOnly: true });
try {
expect(
migrated
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'workboard_boards'")
.get(),
).toEqual({ strict: 1 });
expect(
migrated
.prepare("SELECT 1 AS found FROM workboard_schema_migrations WHERE id = 'schema-3'")
.get(),
).toEqual({ found: 1 });
} finally {
migrated.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -1,3 +1,4 @@
import { copyFileSync, renameSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
@ -258,6 +259,9 @@ describe("Sessions board schema reopening", () => {
});
await store.close();
// Model the prior schema's first admission, rather than altering admitted format facts.
renameSync(dbPath, `${dbPath}.seed`);
copyFileSync(`${dbPath}.seed`, dbPath);
{
using prior = new DatabaseSync(dbPath);
// These are the only schema additions for Sessions boards; preserve the existing migration receipt.

View file

@ -198,32 +198,8 @@ function statfsFixture(type: number): ReturnType<typeof fs.statfsSync> {
};
}
const WORKBOARD_CARD_CHILD_INDEXES = [
["workboard_card_events", "workboard_card_events_card_idx"],
["workboard_card_attempts", "workboard_card_attempts_card_idx"],
["workboard_card_comments", "workboard_card_comments_card_idx"],
["workboard_card_links", "workboard_card_links_card_idx"],
["workboard_card_proof", "workboard_card_proof_card_idx"],
["workboard_card_artifacts", "workboard_card_artifacts_card_idx"],
["workboard_card_notifications", "workboard_card_notifications_card_idx"],
["workboard_worker_logs", "workboard_worker_logs_card_idx"],
] as const;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function explainWorkboardQueryPlan(
db: DatabaseSync,
sql: string,
params: readonly (number | string | null)[] = [],
): string {
const rows = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{
detail?: unknown;
}>;
return rows
.map((row) => (typeof row.detail === "string" ? row.detail : JSON.stringify(row.detail ?? "")))
.join("\n");
}
describe("WorkboardStore", () => {
it("emits monotonic committed changes, ignores no-ops, and isolates listener failures", async () => {
const store = createWorkboardSqliteTestStore({ createStores: createKernelStores });
@ -609,55 +585,6 @@ describe("WorkboardStore", () => {
expect((await first.list()).filter((card) => card.status === "running")).toHaveLength(1);
});
it("restores dropped card child indexes without changing the schema version", async () => {
const dir = tempDirs.make("openclaw-workboard-index-reopen-");
const dbPath = path.join(dir, "workboard.sqlite");
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
await initialized.ready;
await initialized.close();
const db = new DatabaseSync(dbPath);
let initialMigrationIds: Array<{ id: string }>;
try {
initialMigrationIds = db
.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id")
.all() as Array<{ id: string }>;
for (const [, index] of WORKBOARD_CARD_CHILD_INDEXES) {
db.exec(`DROP INDEX ${index}`);
}
} finally {
db.close();
}
const reopened = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
await reopened.ready;
await reopened.close();
const verified = new DatabaseSync(dbPath, { readOnly: true });
try {
const indexes = new Set(
(
verified.prepare("SELECT name FROM sqlite_schema WHERE type = 'index'").all() as Array<{
name: string;
}>
).map((row) => row.name),
);
for (const [table, index] of WORKBOARD_CARD_CHILD_INDEXES) {
expect(indexes).toContain(index);
const plan = explainWorkboardQueryPlan(
verified,
`SELECT * FROM ${table} WHERE card_id = ? ORDER BY ordinal ASC`,
["card-1"],
);
expect(plan).toContain(index);
expect(plan).not.toContain("USE TEMP B-TREE FOR ORDER BY");
}
expect(
verified.prepare("SELECT id FROM workboard_schema_migrations ORDER BY id").all(),
).toEqual(initialMigrationIds);
} finally {
verified.close();
}
});
it("persists boards, cards, subscriptions, and attachment blobs in sqlite", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-"));
const dbPath = path.join(dir, "workboard.sqlite");
@ -864,77 +791,6 @@ describe("WorkboardStore", () => {
}
});
it("migrates a version 2 workboard table to STRICT without losing rows", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-strict-migration-"));
const dbPath = path.join(dir, "workboard.sqlite");
const initialized = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
await initialized.ready;
await initialized.close();
const legacy = new DatabaseSync(dbPath);
try {
legacy.exec(`
INSERT INTO workboard_boards (
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
) VALUES ('legacy', 'Legacy board', NULL, NULL, NULL, NULL, NULL, 1, 2, NULL);
ALTER TABLE workboard_boards RENAME TO workboard_boards_strict;
CREATE TABLE workboard_boards (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
icon TEXT,
color TEXT,
default_workspace_json TEXT,
orchestration_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER
);
INSERT INTO workboard_boards (
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
) SELECT
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
FROM workboard_boards_strict;
DROP TABLE workboard_boards_strict;
DELETE FROM workboard_schema_migrations WHERE id = 'schema-3';
INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at)
VALUES ('schema-2', 1);
`);
} finally {
legacy.close();
}
try {
const migratedStores = createWorkboardSqliteStores({ dbPath, workerModuleUrl });
try {
await expect(migratedStores.boards.lookup("legacy")).resolves.toMatchObject({
board: { id: "legacy", name: "Legacy board" },
});
} finally {
await migratedStores.close();
}
const migrated = new DatabaseSync(dbPath, { readOnly: true });
try {
expect(
migrated
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'workboard_boards'")
.get(),
).toEqual({ strict: 1 });
expect(
migrated
.prepare("SELECT 1 AS found FROM workboard_schema_migrations WHERE id = 'schema-3'")
.get(),
).toEqual({ found: 1 });
} finally {
migrated.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses rollback journaling on network-backed volumes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-network-"));
const dbPath = path.join(dir, "workboard.sqlite");

View file

@ -0,0 +1,195 @@
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteDatabase } from "../../../../src/infra/node-sqlite.js";
import { revokeSqliteDatabaseAdmissions } from "../../../../src/infra/sqlite-database-admission.js";
import { admitSqliteSchema } from "../../../../src/infra/sqlite-schema-facts.js";
import { runSqliteImmediateTransactionSync } from "../../../../src/infra/sqlite-transaction.js";
import { observeHostDataSql } from "../../../../test/helpers/sqlite-statement-execution-counter.js";
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
import { ensureMemoryIndexSchema } from "./memory-schema.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function schemaStatements(queries: string[]): string[] {
return queries.filter((sql) =>
/sqlite_(?:schema|master)|pragma_(?:table|index|foreign_key)|\bPRAGMA\s+(?:main\.)?(?:user_version|schema_version|integrity_check|quick_check|foreign_key_check|table_info|table_xinfo|index_list|index_info|index_xinfo)\b|\b(?:CREATE|ALTER|DROP)\s+(?:TABLE|INDEX|TRIGGER|VIRTUAL)\b/iu.test(
sql,
),
);
}
describe("memory physical schema admission", () => {
it.each(["commit", "rollback", "later DDL", "revocation"])(
"publishes only final committed initialization after %s",
(outcome) => {
const filename = path.join(tempDirs.make("memory-admission-transaction-"), "memory.sqlite");
using db = openNodeSqliteDatabase(filename);
admitSqliteSchema(db);
using peer = openNodeSqliteDatabase(filename);
const initialize = () =>
ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true });
const transact = () =>
runSqliteImmediateTransactionSync(db, () => {
initialize();
if (outcome === "rollback") {
throw new Error("synthetic initialization rollback");
}
if (outcome === "later DDL") {
db.exec("DROP TABLE memory_index_sources");
}
if (outcome === "revocation") {
revokeSqliteDatabaseAdmissions(peer);
}
});
if (outcome === "rollback") {
expect(transact).toThrow("synthetic initialization rollback");
} else {
transact();
}
const observed = observeHostDataSql();
try {
expect(initialize()).toEqual({ ftsAvailable: true });
if (outcome === "commit") {
expect(schemaStatements(observed.queries)).toEqual([]);
} else {
expect(schemaStatements(observed.queries).length).toBeGreaterThan(0);
}
expect(db.prepare("SELECT path FROM memory_index_sources").all()).toEqual([]);
} finally {
observed.restore();
}
},
);
it("reuses admission across handles while backfilling newly imported rows", () => {
const filename = path.join(tempDirs.make("memory-admission-"), "memory.sqlite");
const observed = observeHostDataSql();
try {
for (let opening = 0; opening < 2; opening++) {
using db = openNodeSqliteDatabase(filename);
observed.queries.length = 0;
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
ftsAvailable: true,
});
if (opening === 0) {
expect(schemaStatements(observed.queries).length).toBeGreaterThan(0);
} else {
expect(schemaStatements(observed.queries)).toEqual([]);
}
db.prepare(`INSERT INTO memory_index_chunks
(id, path, source, start_line, end_line, hash, model, text, embedding, updated_at)
VALUES (?, 'notes.md', 'memory', 1, 1, 'hash', 'model', 'body', X'', 17)
`).run(`import-${opening}`);
db.exec(`
INSERT OR IGNORE INTO memory_index_sources (path, source, hash, mtime, size)
VALUES ('notes.md', 'memory', 'hash', 1, 1);
DELETE FROM memory_index_chunks_fts;
DELETE FROM memory_index_paths_fts;
`);
observed.queries.length = 0;
ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true });
expect(schemaStatements(observed.queries)).toEqual([]);
expect(
db
.prepare("SELECT origin_class FROM memory_index_chunk_provenance WHERE chunk_id = ?")
.get(`import-${opening}`),
).toEqual({ origin_class: "untrusted" });
expect(
db
.prepare(
"SELECT id FROM memory_index_chunks_fts WHERE memory_index_chunks_fts MATCH 'body'",
)
.all(),
).toHaveLength(opening + 1);
expect(db.prepare("SELECT path FROM memory_index_paths_fts").all()).toEqual([
{ path: "notes.md" },
]);
}
} finally {
observed.restore();
}
});
it("retries transient FTS initialization failure on a later handle", () => {
const filename = path.join(tempDirs.make("memory-admission-retry-"), "memory.sqlite");
let failFts = true;
const observed = observeHostDataSql((sql) => {
if (failFts && /\bCREATE\s+VIRTUAL\s+TABLE\b/iu.test(sql)) {
failFts = false;
throw Object.assign(new Error("database is locked"), { errcode: 5 });
}
});
try {
{
using db = openNodeSqliteDatabase(filename);
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
ftsAvailable: false,
ftsError: expect.stringContaining("database is locked"),
});
db.exec(`INSERT INTO memory_index_chunks
(id, path, source, start_line, end_line, hash, model, text, embedding, updated_at)
VALUES ('retry', 'notes.md', 'memory', 1, 1, 'hash', 'model', 'recovered body', X'', 17)
`);
}
using db = openNodeSqliteDatabase(filename);
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
ftsAvailable: true,
});
expect(
db
.prepare(
"SELECT id FROM memory_index_chunks_fts WHERE memory_index_chunks_fts MATCH 'recovered'",
)
.all(),
).toEqual([{ id: "retry" }]);
observed.queries.length = 0;
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
ftsAvailable: true,
});
expect(schemaStatements(observed.queries)).toEqual([]);
} finally {
observed.restore();
}
});
it("admits changed options and rejects schema drift in a replacement file", () => {
const filename = path.join(tempDirs.make("memory-admission-replace-"), "memory.sqlite");
{
using db = openNodeSqliteDatabase(filename);
expect(ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toEqual({
ftsAvailable: false,
});
expect(ensureMemoryIndexSchema({ db, cacheEnabled: true, ftsEnabled: true })).toEqual({
ftsAvailable: true,
});
expect(ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toEqual({
ftsAvailable: false,
});
const observed = observeHostDataSql();
try {
expect(ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toEqual({
ftsAvailable: false,
});
expect(schemaStatements(observed.queries)).toEqual([]);
} finally {
observed.restore();
}
expect(
db.prepare("SELECT name FROM sqlite_schema WHERE name = 'memory_index_chunks_fts'").get(),
).toBeUndefined();
}
const replacement = `${filename}.replacement`;
fs.copyFileSync(filename, replacement);
{
using db = new DatabaseSync(replacement);
db.exec("ALTER TABLE memory_index_sources ADD COLUMN unexpected TEXT");
}
fs.renameSync(replacement, filename);
using db = openNodeSqliteDatabase(filename);
expect(() => ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false })).toThrow(
"canonical memory source identity schema is invalid",
);
});
});

View file

@ -180,6 +180,33 @@ export function rebuildMemoryChunkFts(db: DatabaseSync, ftsTable: string): void
`);
}
export function reconcileMemoryChunkFtsRows(
db: DatabaseSync,
ftsTable: string,
force = false,
): void {
const rowCounts = db
.prepare(
`SELECT
(SELECT COUNT(*) FROM ${MEMORY_INDEX_CHUNKS_TABLE}) AS canonical_count,
(SELECT COUNT(*) FROM ${ftsTable}) AS derived_count`,
)
// SAFETY: both scalar aggregate subqueries always return their named numeric columns.
.get() as { canonical_count: number; derived_count: number };
if (force || rowCounts.canonical_count !== rowCounts.derived_count) {
rebuildMemoryChunkFts(db, ftsTable);
}
}
export function backfillMemoryPathFtsRows(db: DatabaseSync): void {
db.exec(`
INSERT INTO ${MEMORY_INDEX_PATHS_FTS_TABLE} (rowid, path, source)
SELECT id, path, source
FROM ${MEMORY_INDEX_SOURCES_TABLE}
WHERE NOT EXISTS (SELECT 1 FROM ${MEMORY_INDEX_PATHS_FTS_TABLE} LIMIT 1);
`);
}
/** Reconcile and backfill the derived body index in one atomic savepoint. */
export function ensureMemoryChunkFtsSchema(params: {
db: DatabaseSync;
@ -211,14 +238,6 @@ export function ensureMemoryChunkFtsSchema(params: {
` end_line UNINDEXED\n` +
`${params.tokenizeClause});`,
);
const rowCounts = params.db
.prepare(
`SELECT
(SELECT COUNT(*) FROM ${MEMORY_INDEX_CHUNKS_TABLE}) AS canonical_count,
(SELECT COUNT(*) FROM ${params.ftsTable}) AS derived_count`,
)
// SAFETY: both scalar aggregate subqueries always return their named numeric columns.
.get() as { canonical_count: number; derived_count: number };
// FTS is fully derived. A cardinality mismatch proves that an ordinary
// schema ensure cannot leave the populated index untouched.
const canonical = params.ftsTable === MEMORY_INDEX_FTS_TABLE;
@ -231,9 +250,7 @@ export function ensureMemoryChunkFtsSchema(params: {
);
// Pre-rowid indexes can have matching counts but unrelated FTS identities.
// Installing their maintenance contract owns the one-time identity rebuild.
if (!hasRowidMaintenance || rowCounts.canonical_count !== rowCounts.derived_count) {
rebuildMemoryChunkFts(params.db, params.ftsTable);
}
reconcileMemoryChunkFtsRows(params.db, params.ftsTable, !hasRowidMaintenance);
if (canonical) {
ensureMemoryChunkFtsTriggers(params.db);
}
@ -295,13 +312,10 @@ export function ensureMemoryPathFtsSchema(params: {
source UNINDEXED
${params.tokenizeClause}
);
-- The initial copy and trigger installation share this savepoint. Once
-- populated, the triggers own completeness; per-row FTS probes are too costly.
INSERT INTO ${MEMORY_INDEX_PATHS_FTS_TABLE} (rowid, path, source)
SELECT id, path, source
FROM ${MEMORY_INDEX_SOURCES_TABLE}
WHERE NOT EXISTS (SELECT 1 FROM ${MEMORY_INDEX_PATHS_FTS_TABLE} LIMIT 1);
`);
// The initial copy and trigger installation share this savepoint. Once
// populated, the triggers own completeness; per-row FTS probes are too costly.
backfillMemoryPathFtsRows(params.db);
ensureMemoryPathFtsTriggers(params.db);
params.db.exec("RELEASE ensure_memory_index_paths_fts");
} catch (err) {

View file

@ -30,6 +30,18 @@ export function ensureMemoryChunkProvenance(db: DatabaseSync): void {
// legacy/imported rows without changing the canonical chunk table.
db.exec("DROP TRIGGER IF EXISTS memory_index_chunk_provenance_after_insert");
db.exec(MEMORY_INDEX_CHUNK_PROVENANCE_SCHEMA_SQL);
backfillMemoryChunkProvenance(db);
};
if (db.isTransaction) {
ensure();
return;
}
runSqliteImmediateTransactionSync(db, ensure);
}
/** Imported rows still need provenance after their physical schema has been admitted. */
export function backfillMemoryChunkProvenance(db: DatabaseSync): void {
const backfill = () => {
// Materialize indexed missing IDs before touching wide chunk payloads.
// CROSS JOIN keeps that empty-set check ahead of the chunk-table lookup.
db.exec(`
@ -58,8 +70,8 @@ export function ensureMemoryChunkProvenance(db: DatabaseSync): void {
`);
};
if (db.isTransaction) {
ensure();
backfill();
return;
}
runSqliteImmediateTransactionSync(db, ensure);
runSqliteImmediateTransactionSync(db, backfill);
}

View file

@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteDatabase } from "../../../../src/infra/node-sqlite.js";
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
import { encodeMemoryEmbedding } from "./embedding-vector.js";
import { ensureMemoryRecallMetadataSchema } from "./memory-schema-recall.js";
@ -17,7 +18,7 @@ describe("memory index schema", () => {
(kind) => {
const databasePath = path.join(tempDirs.make("memory-schema-index-"), "memory.sqlite");
if (kind === "existing") {
using seed = new DatabaseSync(databasePath);
using seed = openNodeSqliteDatabase(databasePath);
ensureMemoryIndexSchema({ db: seed, cacheEnabled: false, ftsEnabled: false });
seed.exec(`
CREATE INDEX IF NOT EXISTS idx_memory_index_chunks_path ON memory_index_chunks(path);

View file

@ -6,6 +6,7 @@ import {
MEMORY_INDEX_STATE_TABLE,
} from "./memory-schema-base.js";
import {
backfillMemoryPathFtsRows,
dropDisabledMemoryFts,
dropMemoryChunkFtsTriggers,
dropMemoryPathFtsTriggers,
@ -16,14 +17,19 @@ import {
MEMORY_INDEX_FTS_TABLE,
MEMORY_INDEX_PATHS_FTS_TABLE,
MEMORY_INDEX_SOURCES_TABLE,
reconcileMemoryChunkFtsRows,
} from "./memory-schema-fts.js";
import * as provenanceSchema from "./memory-schema-provenance.js";
import { ensureMemoryRecallMetadataSchema } from "./memory-schema-recall.js";
import { migrateMemoryIndexStorage } from "./memory-schema-storage-migration.js";
import {
canReuseSqliteSchemaInTransaction,
getSqliteDatabaseAdmission,
migrateSqliteSchemaToStrict,
migrateSqliteSchemaToStrictInTransaction,
publishSqliteDatabaseAdmission,
runSqliteImmediateTransactionSync,
type SqliteDatabaseAdmissionKey,
} from "./openclaw-runtime-sqlite.js";
export {
markInvalidImportedMemoryEmbeddings,
@ -83,6 +89,20 @@ type TableColumnInfo = {
hidden: number;
};
type MemoryIndexSchemaAdmission = { ftsAvailable: boolean };
function readMemoryIndexSchemaAdmission(value: unknown): MemoryIndexSchemaAdmission | undefined {
if (
typeof value !== "object" ||
value === null ||
!("ftsAvailable" in value) ||
typeof value.ftsAvailable !== "boolean"
) {
return undefined;
}
return { ftsAvailable: value.ftsAvailable };
}
function tableColumnInfo(db: DatabaseSync, tableName: string): TableColumnInfo[] {
const rows = db.prepare(`PRAGMA main.table_xinfo(${tableName})`).all() as Array<{
name?: unknown;
@ -321,6 +341,46 @@ export function ensureMemoryIndexSchema(params: {
ftsEnabled: boolean;
ftsTokenizer?: "unicode61" | "trigram";
}): { ftsAvailable: boolean; ftsError?: string } {
const embeddingCacheTable = params.embeddingCacheTable ?? MEMORY_EMBEDDING_CACHE_TABLE;
const ftsTable = params.ftsTable ?? MEMORY_INDEX_FTS_TABLE;
const tokenizer = params.ftsTokenizer ?? "unicode61";
const admissionKey: SqliteDatabaseAdmissionKey<MemoryIndexSchemaAdmission> = {
name: `memory.index-schema:${JSON.stringify([
embeddingCacheTable,
params.cacheEnabled,
ftsTable,
params.ftsEnabled,
tokenizer,
])}`,
schemaDependent: true,
read: readMemoryIndexSchemaAdmission,
};
const admitted = getSqliteDatabaseAdmission(params.db, admissionKey);
if (admitted) {
provenanceSchema.backfillMemoryChunkProvenance(params.db);
if (admitted.ftsAvailable) {
try {
const reconcile = () => {
reconcileMemoryChunkFtsRows(params.db, ftsTable);
if (ftsTable === MEMORY_INDEX_FTS_TABLE) {
backfillMemoryPathFtsRows(params.db);
}
};
if (params.db.isTransaction) {
reconcile();
} else {
runSqliteImmediateTransactionSync(params.db, reconcile);
}
} catch (err) {
if (ftsTable === MEMORY_INDEX_FTS_TABLE) {
dropMemoryChunkFtsTriggers(params.db);
dropMemoryPathFtsTriggers(params.db);
}
return { ftsAvailable: false, ftsError: formatErrorMessage(err) };
}
}
return admitted;
}
if (
tableHasExactColumns(params.db, "meta", ["key", "value"]) &&
tableHasExactColumns(params.db, "files", ["path", "source", "hash", "mtime", "size"]) &&
@ -341,8 +401,6 @@ export function ensureMemoryIndexSchema(params: {
"Retired memory index format detected. Preserve a complete copy of your state and configuration, then use OpenClaw 2026.9.7 to migrate a compatible copy of this index before retrying the upgrade.",
);
}
const embeddingCacheTable = params.embeddingCacheTable ?? MEMORY_EMBEDDING_CACHE_TABLE;
const ftsTable = params.ftsTable ?? MEMORY_INDEX_FTS_TABLE;
params.db.exec(
buildMemoryIndexStrictSchema({
embeddingCacheTable,
@ -405,7 +463,6 @@ export function ensureMemoryIndexSchema(params: {
let ftsError: string | undefined;
if (params.ftsEnabled) {
try {
const tokenizer = params.ftsTokenizer ?? "unicode61";
const tokenizeClause = tokenizer === "trigram" ? `, tokenize='trigram case_sensitive 0'` : "";
ensureMemoryChunkFtsSchema({ db: params.db, ftsTable, tokenizeClause });
// Deprecated custom FTS tables preserve their body-only contract. The
@ -423,5 +480,8 @@ export function ensureMemoryIndexSchema(params: {
}
}
if (!params.ftsEnabled || ftsAvailable) {
publishSqliteDatabaseAdmission(params.db, admissionKey, { ftsAvailable });
}
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
}

View file

@ -12,4 +12,9 @@ export {
type SqliteTableContractReader,
} from "../../../../src/infra/sqlite-schema-contract.js";
export { canReuseSqliteSchemaInTransaction } from "../../../../src/infra/sqlite-schema-facts.js";
export {
getSqliteDatabaseAdmission,
publishSqliteDatabaseAdmission,
type SqliteDatabaseAdmissionKey,
} from "../../../../src/infra/sqlite-database-admission.js";
export { runSqliteImmediateTransactionSync } from "../../../../src/infra/sqlite-transaction.js";

View file

@ -1,4 +1,4 @@
import type { WorkerOptions } from "node:worker_threads";
import type { Transferable, WorkerOptions } from "node:worker_threads";
import type { RetainedOperation } from "./retained-operation.js";
import type { WorkerLifecycle, RetainedNativeWorker } from "./worker-lifecycle.js";
import type { WorkerComputeCapacity } from "./worker-task-capacity.js";
@ -32,7 +32,8 @@ export type WorkerTaskHost = {
serviceNativeWorkers(workers: readonly RetainedNativeWorker[]): void;
prepareResources(): Promise<unknown>;
releaseTemporaryDirectory(directory: string): Promise<void>;
captureTaskContext(): unknown;
captureTaskContext(worker: WorkerLifecycle): unknown;
taskContextTransferList?(context: unknown): readonly Transferable[];
createTaskObserver?(url: URL): (operation?: string) => WorkerTaskObservation;
receiveMessage(worker: WorkerLifecycle, message: unknown): boolean;
workerStarted(worker: WorkerLifecycle, pool: object): void;

View file

@ -66,13 +66,14 @@ export function sendWorkerTaskInput<Input, Output>(params: {
}
const transferList = task.options.transferList?.(input);
if (!task.done) {
const context = params.host.captureTaskContext(worker);
postWorkerTaskInput(
worker,
slot,
task,
input,
transferList,
params.host.captureTaskContext(),
[...(transferList ?? []), ...(params.host.taskContextTransferList?.(context) ?? [])],
context,
);
}
} catch (error) {

View file

@ -997,6 +997,7 @@ src/infra/secure-random.ts
src/infra/semver.ts
src/infra/session-cost-usage-cache-migration.ts
src/infra/session-cost-usage-rollup-codec.ts
src/infra/session-cost-usage-worker.types.ts
src/infra/shell-env.ts
src/infra/shell-inline-command.ts
src/infra/shell-wrapper-resolution.ts
@ -1007,6 +1008,10 @@ src/infra/sqlite-audit-record.kernel.ts
src/infra/sqlite-backup.ts
src/infra/sqlite-busy-timeout.ts
src/infra/sqlite-commit-receipt.ts
src/infra/sqlite-database-admission-key.ts
src/infra/sqlite-database-admission-record.ts
src/infra/sqlite-database-admission-turn.ts
src/infra/sqlite-database-admission.ts
src/infra/sqlite-error-diagnostics.ts
src/infra/sqlite-existing-database.ts
src/infra/sqlite-file-copy.ts
@ -1022,6 +1027,7 @@ src/infra/sqlite-jsonl-budget.ts
src/infra/sqlite-live-snapshot.ts
src/infra/sqlite-lifecycle-errors.ts
src/infra/sqlite-maintenance-cache.ts
src/infra/sqlite-native-admission.ts
src/infra/sqlite-native-observer.ts
src/infra/sqlite-number.ts
src/infra/sqlite-page-cache-residency.ts
@ -1047,6 +1053,7 @@ src/infra/sqlite-recovery-files.ts
src/infra/sqlite-recovery-snapshot.ts
src/infra/sqlite-rollback-recovery.ts
src/infra/sqlite-runtime-version.ts
src/infra/sqlite-schema-admission.ts
src/infra/sqlite-schema-contract-assembly.ts
src/infra/sqlite-schema-contract.ts
src/infra/sqlite-schema-ensure.ts
@ -1054,6 +1061,7 @@ src/infra/sqlite-schema-facts.ts
src/infra/sqlite-schema-header.ts
src/infra/sqlite-schema-issues.ts
src/infra/sqlite-schema-mutation.ts
src/infra/sqlite-schema-scope.ts
src/infra/sqlite-schema-sql.ts
src/infra/sqlite-snapshot-file.ts
src/infra/sqlite-snapshot-policy.ts
@ -1093,6 +1101,7 @@ src/infra/sqlite-worker-client.ts
src/infra/sqlite-worker-contract.ts
src/infra/sqlite-worker-identity.ts
src/infra/sqlite-worker-operation-admission.ts
src/infra/sqlite-worker-operation-settlement.ts
src/infra/sqlite-worker-request-class.ts
src/infra/sqlite-worker-state-context.ts
src/infra/sqlite-worker-store.ts
@ -1159,6 +1168,7 @@ src/infra/windows-port-pids.ts
src/infra/windows-private-directory.ts
src/infra/windows-process-census.ts
src/infra/windows-process-start.ts
src/infra/worker-ancestry.ts
src/infra/worker-cpu.ts
src/infra/worker-diagnostic-script.ts
src/infra/worker-idle-gc.ts
@ -1677,6 +1687,7 @@ src/state/openclaw-agent-db-session-provenance.ts
src/state/openclaw-agent-db-terminal.ts
src/state/openclaw-agent-db-transaction.ts
src/state/openclaw-agent-db-validation-cache.ts
src/state/openclaw-agent-db-validation-facts.ts
src/state/openclaw-agent-db.paths.ts
src/state/openclaw-agent-db.ts
src/state/openclaw-agent-db.wal.ts
@ -1720,6 +1731,7 @@ src/state/openclaw-database-verify.ts
src/state/openclaw-quarantine-error.ts
src/state/openclaw-quarantine-store.ts
src/state/openclaw-state-db-additive-columns.ts
src/state/openclaw-state-db-admission.ts
src/state/openclaw-state-db-async-lifecycle.ts
src/state/openclaw-state-db-audit-migration.ts
src/state/openclaw-state-db-borrow.ts

View file

@ -1,15 +1,19 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { copyFileSync, renameSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi, type MockInstance } from "vitest";
import { observeSqliteReadSql } from "../../test/helpers/sqlite-statement-execution-counter.js";
import * as nodeSqlite from "../infra/node-sqlite.js";
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../state/openclaw-agent-db-contract.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { loadPersistedAuthProfileStore } from "./auth-profiles/persisted.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./auth-profiles/runtime-snapshots.js";
import { acquireAuthProfileReadDatabase } from "./auth-profiles/sqlite-read-pool.js";
import {
closeAuthProfileReadPool,
resolveAuthProfileDatabasePath,
@ -51,6 +55,39 @@ function withReaders(
}
describe("auth profile sqlite reader lifecycle", () => {
it("reuses format admission across pool reopen and validates a replacement file", async () => {
await withReaders((agentDir) => {
const pathname = resolveAuthProfileDatabasePath(agentDir);
const observed = observeSqliteReadSql(nodeSqlite.requireNodeSqlite().StatementSync.prototype);
const formatQueries = () =>
observed.queries.filter((sql) =>
/sqlite_(?:schema|master)|\bPRAGMA\s+(?:user_version|schema_version|table_info|table_xinfo|index_list|index_info|index_xinfo)\b/iu.test(
sql,
),
);
try {
expect(acquireAuthProfileReadDatabase(pathname).status).toBe("readable");
closeAuthProfileReadPool({ kind: "database", databasePath: pathname });
observed.queries.length = 0;
expect(acquireAuthProfileReadDatabase(pathname).status).toBe("readable");
expect(formatQueries()).toEqual([]);
closeAuthProfileReadPool({ kind: "database", databasePath: pathname });
const replacement = `${pathname}.replacement`;
copyFileSync(pathname, replacement);
{
using database = new DatabaseSync(replacement);
database.exec(`PRAGMA user_version=${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`);
}
renameSync(replacement, pathname);
observed.queries.length = 0;
expect(acquireAuthProfileReadDatabase(pathname).status).toBe("unreadable");
expect(formatQueries().length).toBeGreaterThan(0);
} finally {
observed.restore();
}
});
});
it("observes foreign commits through cached readers and closes them on snapshot replacement", async () => {
await withReaders((agentDir, open) => {
expect(loadPersistedAuthProfileStore(agentDir)).toMatchObject(apiKeyStore("qa-main"));

View file

@ -306,7 +306,7 @@ describe("auth profile sqlite store", () => {
const inspection = vi
.spyOn(databaseIdentity, "inspectDatabasePathIdentitySync")
.mockImplementation((pathname) => {
if (path.resolve(pathname) === path.resolve(sourcePath)) {
if (!sourceInspectionFailed && path.resolve(pathname) === path.resolve(sourcePath)) {
sourceInspectionFailed = true;
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
}

View file

@ -12,6 +12,7 @@ import {
enableNodeSqliteKyselyStatementCache,
} from "../../infra/kysely-sync.js";
import { isPathInside } from "../../infra/path-guards.js";
import { admitSqliteSchema } from "../../infra/sqlite-schema-facts.js";
import { openSqliteReadOnlyDatabase } from "../../infra/sqlite-snapshot-source.js";
import { readSqliteUserVersion } from "../../infra/sqlite-user-version.js";
import {
@ -159,8 +160,16 @@ export function acquireAuthProfileReadDatabase(
let readable = false;
try {
enableNodeSqliteKyselyStatementCache(db);
// The pooled reader bypasses canonical bootstrap and validates its schema on open.
readable = readSqliteUserVersion(db) <= OPENCLAW_AGENT_SCHEMA_VERSION;
if (inspection) {
readable = readSqliteUserVersion(db) <= OPENCLAW_AGENT_SCHEMA_VERSION;
} else {
admitSqliteSchema(db, (version) => {
if (version > OPENCLAW_AGENT_SCHEMA_VERSION) {
throw new Error("Auth profile database uses a newer agent schema");
}
});
readable = true;
}
} catch {
// Invalid readers are disposed below, where native close failures propagate.
}

View file

@ -167,10 +167,16 @@ it.each(["schema", "owner"] as const)(
new DatabaseSync(target.storePath).close();
} else {
await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 });
openOpenClawAgentDatabase({ agentId: "main", path: target.storePath }).db.exec(
"UPDATE schema_meta SET agent_id = 'different' WHERE meta_key = 'primary'",
);
await closeOpenClawAgentDatabaseByPathAsync(target.storePath);
fs.renameSync(target.storePath, `${target.storePath}.template`);
fs.copyFileSync(
`${target.storePath}.template`,
target.storePath,
fs.constants.COPYFILE_EXCL,
);
const database = new DatabaseSync(target.storePath);
database.exec("UPDATE schema_meta SET agent_id = 'different' WHERE meta_key = 'primary'");
database.close();
}
await expect(
loadCliSessionContextEngineMessages({ sessionTarget: target }),

View file

@ -49,7 +49,10 @@ describe("Gateway catalog worker agent database readers", () => {
try {
expect(() => leaveWalMode(deleted!)).toThrow(/locked/);
expect(() => leaveWalMode(survivor!)).toThrow(/locked/);
await closeDeletedAgentDatabases(fixture.agentIds[0]!, [deleted!]);
await closeDeletedAgentDatabases(fixture.agentIds[0]!, [deleted!], {
assertCurrentFinal() {},
async assertCurrentAsync() {},
});
expect(leaveWalMode(deleted!)).toBe("delete");
const survivorProfileId = `fleet:${fixture.agentIds[1]!}`;

View file

@ -7,6 +7,7 @@ import { getRuntimeConfig } from "../../config/config.js";
import { setRuntimeConfigSnapshot } from "../../config/runtime-snapshot.js";
import { snapshotFiles } from "../../infra/state-migrations.caller-mode.test-helpers.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.doctor.js";
import { inspectLegacyAgentDir } from "../../infra/state-migrations.legacy-sessions.js";
import type { Model } from "../../llm/types.js";
import { EMPTY_LEGACY_SESSION_SURFACES } from "../../plugins/legacy-session-surfaces.types.js";
import {
@ -76,6 +77,8 @@ it("keeps a legacy SDK store when Doctor's configured owner differs", async () =
status: "owned",
agentId: "main",
});
// Ownership may use admitted facts; payload inspection materializes native WAL sidecars.
expect(inspectLegacyAgentDir(legacyDir)).toEqual({ status: "payload" });
const before = snapshotFiles(legacyDir);
const cfg = { agents: { entries: { worker: {} } }, plugins: { enabled: false } };
await state.writeConfig(cfg);

View file

@ -115,6 +115,8 @@ describe("subagent registry sqlite store", () => {
saveSubagentRegistryToSqlite(new Map([[legacy.runId, legacy]]));
const original = openOpenClawStateDatabase();
closeOpenClawStateDatabaseForTest();
await fs.rename(original.path, `${original.path}.template`);
await fs.copyFile(`${original.path}.template`, original.path, fs.constants.COPYFILE_EXCL);
const old = new DatabaseSync(original.path);
try {
for (const column of ["requester_store_path", "controller_store_path"]) {

View file

@ -296,7 +296,10 @@ describe("managed worktree registry", () => {
it("adds the provisioned-path ledger to an existing worktree registry", async () => {
const databasePath = openOpenClawStateDatabase({ env }).path;
await closeOpenClawStateDatabaseAsync();
closeOpenClawStateDatabaseForTest();
await fs.rename(databasePath, `${databasePath}.template`);
await fs.copyFile(`${databasePath}.template`, databasePath, fs.constants.COPYFILE_EXCL);
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec(`

View file

@ -224,7 +224,7 @@ describe("ManagedWorktreeService capacity", () => {
const stat = fsSync.statSync;
vi.spyOn(fsSync, "statSync").mockImplementation((...args) => {
const result = stat(...args);
if (result) {
if (result?.isDirectory()) {
result.dev = isData(args[0]) ? 2 : 1;
}
return result;

View file

@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
@ -51,6 +52,8 @@ describe("worktree template registry", () => {
const database = openOpenClawStateDatabase({ env });
const databasePath = database.path;
await closeStateDatabaseForTest();
await fs.rename(databasePath, `${databasePath}.template`);
await fs.copyFile(`${databasePath}.template`, databasePath, fs.constants.COPYFILE_EXCL);
const { DatabaseSync } = requireNodeSqlite();
const older = new DatabaseSync(databasePath);
older.exec("DROP TABLE IF EXISTS worktree_templates");

View file

@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { constants, copyFileSync, existsSync, renameSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoardWidgetMaterializedPutParams } from "../../packages/gateway-protocol/src/index.js";
@ -805,6 +805,8 @@ describe("SqliteBoardStore persistence", () => {
await closeOpenClawStateDatabaseAsync();
closeOpenClawStateDatabaseForTest();
renameSync(databasePath, `${databasePath}.template`);
copyFileSync(`${databasePath}.template`, databasePath, constants.COPYFILE_EXCL);
const { DatabaseSync } = requireNodeSqlite();
const existingV14 = new DatabaseSync(databasePath);
restoreEmptyV21StorageForHistoricalFixture(existingV14);

View file

@ -4,12 +4,12 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { replaceSessionEntrySync } from "../config/sessions/session-accessor.entry.js";
import { composeSessionSourceAssertion } from "../config/sessions/session-source-authority.js";
import {
failSqliteWorkerSlot,
receiveSqliteWorkerReply,
settleFailedSqliteWorkerJobs,
settleSqliteWorkerJob,
withSqliteWorkerCleanupFailure,
} from "../infra/sqlite-worker-broker-reply.js";
import type { Job } from "../infra/sqlite-worker-broker.types.js";
import type { Actor, Job } from "../infra/sqlite-worker-broker.types.js";
import {
isSqliteWorkerError,
SqliteWorkerError,
@ -112,9 +112,11 @@ async function receiveExecutedFailure(retire: boolean) {
},
detach() {},
};
const slot: Parameters<typeof receiveSqliteWorkerReply>[0] = {
actors: new Set(),
const slot: Parameters<typeof receiveSqliteWorkerReply>[0] &
Parameters<typeof failSqliteWorkerSlot>[0] = {
actors: new Set<Actor>(),
current: job,
queue: [],
worker: {
postMessage() {
throw new Error("A failure reply must not request another result frame");
@ -136,20 +138,11 @@ async function receiveExecutedFailure(retire: boolean) {
},
},
{
fail(reason, currentError, openOutcome) {
if (!(reason instanceof Error)) {
throw new Error("Expected a decoded worker error");
}
const current = slot.current;
slot.current = undefined;
slot.failed = new SqliteWorkerError(reason.message, "unavailable");
settleFailedSqliteWorkerJobs({
queuedError: slot.failed,
current,
queued: [],
error: reason,
fail(reason, currentError, openOutcome, completed) {
failSqliteWorkerSlot(slot, reason, {
currentError,
openOutcome,
completed,
retire: async () => {
events.push("retired");
},

View file

@ -10,6 +10,7 @@ import { createConfigIO } from "../../config/io.js";
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
import { appendTranscriptEventsInTransaction } from "../../config/sessions/session-accessor.sqlite-transcript-store.js";
import { readDaemonRuntimePin } from "../../daemon/runtime-pin-state.js";
import { openNodeSqliteDatabase } from "../../infra/node-sqlite.js";
import {
createPackageIntegrityReader,
type PackageLauncherFingerprint,
@ -551,7 +552,8 @@ it.each([
const originalRuntimePin = original
? readDaemonRuntimePin({ kind: "gateway", env }, { programArguments: [] })
: undefined;
const migrated = new DatabaseSync(database.path);
// The migration owner publishes its committed schema to the already-running updater.
const migrated = openNodeSqliteDatabase(database.path);
try {
migrated.exec(`
BEGIN IMMEDIATE;

View file

@ -254,7 +254,7 @@ it("drains the local cache and excludes reopening throughout awaited removal", a
]),
).toBe("removing");
expect(database.db.isOpen).toBe(false);
expect(() => retainedStatement.get()).toThrow(/finalized/);
expect(() => retainedStatement.get()).toThrow(/finalized|database is not open/);
const before = fs.statSync(database.path, { bigint: true });
expect(() => openOpenClawStateDatabase(options)).toThrow("offline maintenance");
expect(fs.statSync(database.path, { bigint: true })).toEqual(before);

View file

@ -7,6 +7,7 @@ import { writeTextAtomic } from "../infra/json-files.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import { isSqliteCorruptionError } from "../infra/sqlite-error-diagnostics.js";
import { SQLITE_SIDECAR_SUFFIXES } from "../infra/sqlite-files.js";
import { invalidateSqliteSchemaFacts } from "../infra/sqlite-schema-facts.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { hasSqliteFileFamily } from "../state/agent-deletion-discovery.js";
import {
@ -33,6 +34,8 @@ import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
type JournalDatabase = Pick<OpenClawStateDatabase, "db" | "path">;
function readRecoverySource(database: JournalDatabase) {
// Recovery must inspect the damaged catalog instead of the runtime's admitted shape.
invalidateSqliteSchemaFacts(database.db);
const held = tableExists(database.db, "migration_sources")
? readAgentDeletionRecoveryHolds(database)
: [];

View file

@ -115,15 +115,18 @@ describe("container image replacement Doctor repair and startup readiness", () =
await withContainerState(async (stateDir) => {
const databasePath = seedSchema19Agent(stateDir);
const original = fs.readFileSync(databasePath);
await withAgentDatabaseStartupAdmission(async () => {
await expect(runStartupConfigPreflight({ gateway: true })).rejects.toMatchObject({
code: 78,
});
expect(fs.readFileSync(databasePath)).toEqual(original);
await repairContainerState();
await runStartupConfigPreflight({ gateway: true });
expect(listAgentDatabaseAdmissionRefusals()).toEqual([]);
});
await withAgentDatabaseStartupAdmission(
async () => {
await expect(runStartupConfigPreflight({ gateway: true })).rejects.toMatchObject({
code: 78,
});
expect(fs.readFileSync(databasePath)).toEqual(original);
await repairContainerState();
await runStartupConfigPreflight({ gateway: true });
expect(listAgentDatabaseAdmissionRefusals()).toEqual([]);
},
{ deferInspections: false },
);
const backups = fs
.readdirSync(path.dirname(databasePath))
.filter((name) => name.startsWith(`${path.basename(databasePath)}.pre-startup-migration-`));

View file

@ -3,10 +3,10 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { expect, it, vi } from "vitest";
import { createSystemdCommandQuery } from "../daemon/systemd-command-query.js";
import * as integrity from "../infra/sqlite-integrity.js";
import * as snapshots from "../infra/sqlite-snapshot-source.js";
import { readUpdateRunDriver } from "../infra/update-run-driver.js";
import { createUpdateRun, recordUpdateRunRepairContinuation } from "../infra/update-run-ledger.js";
import * as ledger from "../infra/update-run-ledger.js";
import { recordOpenClawDatabaseQuarantine } from "../state/openclaw-quarantine-store.js";
import { createOpenClawDatabaseMaintenanceScope } from "../state/openclaw-state-db-async-lifecycle.js";
import {
@ -28,7 +28,7 @@ function maintenanceScope(admission: () => void) {
});
}
it("admits fast systemd inspection after slow continuation integrity validation", async () => {
it("admits fast systemd inspection after a slow continuation write", async () => {
const { env, assertIsolation } = fixture();
const run = createUpdateRun(
{ trigger: "cli", origin: { driver: readUpdateRunDriver() } },
@ -36,9 +36,9 @@ it("admits fast systemd inspection after slow continuation integrity validation"
);
let elapsed = 0;
vi.spyOn(performance, "now").mockImplementation(() => elapsed);
const validate = integrity.assertSqliteIntegrity;
vi.spyOn(integrity, "assertSqliteIntegrity").mockImplementation((...args) => {
const result = validate(...args);
const recordContinuation = ledger.recordUpdateRunRepairContinuation;
vi.spyOn(ledger, "recordUpdateRunRepairContinuation").mockImplementation((...args) => {
const result = recordContinuation(...args);
elapsed += 6_000;
return result;
});

View file

@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import fs from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import * as restartHealthProbe from "../cli/daemon-cli/restart-health-probe.js";
@ -232,7 +233,8 @@ async function runDoctorFinishForStoppedUnit(
}
const pathname = openOpenClawStateDatabase().path;
closeOpenClawStateDatabaseForTest();
const db = openNodeSqliteDatabase(pathname);
// Damaged legacy bytes are input to Doctor, not a managed schema publication.
const db = new DatabaseSync(pathname);
try {
db.exec(
"CREATE TABLE IF NOT EXISTS skill_workshop_collection_reviews (review_id TEXT NOT NULL PRIMARY KEY, owner_agent_id TEXT NOT NULL, backup_id TEXT NOT NULL, create_time INTEGER NOT NULL, kept_names_json TEXT NOT NULL, written_names_json TEXT NOT NULL, dropped_json TEXT NOT NULL) STRICT; CREATE INDEX idx_skill_workshop_collection_reviews_workspace_time ON skill_workshop_collection_reviews(review_id, create_time DESC);",

View file

@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import * as replaceFile from "@openclaw/fs-safe/atomic";
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "../agents/sessions/session-manager.js";
@ -547,6 +548,9 @@ describe("runDoctorSessionSqlite", () => {
const databaseBefore = fs.readFileSync(sqlitePath);
expect(fs.existsSync(`${sqlitePath}-wal`)).toBe(false);
expect(fs.existsSync(`${sqlitePath}-shm`)).toBe(false);
// Keep real source sidecars present independently of cached owner inspection.
using sourceReader = new DatabaseSync(sqlitePath, { readOnly: true });
sourceReader.prepare("SELECT count(*) FROM sqlite_schema").get();
const preview = inspectSessionSqliteRecovery({ cfg: {}, env: store.env });
expect(preview.artifacts.find((item) => item.path === originalMove.archivePath)?.outcome).toBe(
"candidate",
@ -556,7 +560,6 @@ describe("runDoctorSessionSqlite", () => {
preview,
readConfig: async () => ({}),
confirm: async () => {
// The actual read-only owner inspection creates these sidecars before confirmation.
expect(fs.existsSync(`${sqlitePath}-wal`)).toBe(true);
expect(fs.existsSync(`${sqlitePath}-shm`)).toBe(true);
return true;

View file

@ -2,11 +2,13 @@ import { spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import { readSqliteReaderDiagnosticsForPath } from "../infra/sqlite-reader-lifecycle.js";
import { admitSqliteSchema } from "../infra/sqlite-schema-facts.js";
import { isSqlitePathOnBtrfs, setSqliteDirectoryNoCow } from "../infra/sqlite-wal-filesystem.js";
import { openOpenClawAgentDatabase } from "../state/openclaw-agent-db.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
@ -79,7 +81,7 @@ async function repair(assertCurrent = () => {}) {
describe("Doctor btrfs NOCOW", () => {
it.skipIf(process.platform !== "linux")(
"rewrites shared and agent stores after the real Doctor maintenance scope drains",
"rewrites shared and agent stores after Doctor drains native and admission descriptors",
async () => {
await withOpenClawTestState(
{ scenario: "external-service", label: "doctor-nocow-owner" },
@ -167,6 +169,55 @@ describe("Doctor btrfs NOCOW", () => {
},
);
it.skipIf(process.platform !== "linux")(
"keeps admission descriptors when an unknown native handle blocks NOCOW repair",
async () => {
seedDatabase();
const admitted = openNodeSqliteDatabase(sqlitePath);
try {
admitSqliteSchema(admitted);
} finally {
admitted.close();
}
const unknown = new DatabaseSync(sqlitePath);
unknown.exec("BEGIN IMMEDIATE");
const identity = fs.statSync(sqlitePath);
const sourceDescriptors = () =>
fs
.readdirSync("/proc/self/fd")
.flatMap((entry) => {
try {
const descriptor = Number(entry);
const file = fs.fstatSync(descriptor);
return file.dev === identity.dev && file.ino === identity.ino ? [descriptor] : [];
} catch {
return [];
}
})
.toSorted((left, right) => left - right);
const descriptors = sourceDescriptors();
expect(descriptors.length).toBeGreaterThanOrEqual(2);
const native =
await vi.importActual<typeof import("node:child_process")>("node:child_process");
const fakeTools = vi.mocked(spawnSync).getMockImplementation()!;
vi.mocked(spawnSync).mockImplementation((command, args, options) =>
command === "fuser"
? native.spawnSync(command, args, options)
: fakeTools(command, args, options),
);
try {
expect((await repair()).join("\n")).toContain("store files are open");
expect(sourceDescriptors()).toEqual(descriptors);
expect(unknown.isTransaction).toBe(true);
expect(fs.statSync(sqlitePath).ino).toBe(identity.ino);
expect(fixture.exchanges).toBe(0);
} finally {
unknown.exec("ROLLBACK");
unknown.close();
}
},
);
it("reports existing CoW stores, skips other filesystems and explains missing tooling", () => {
fs.writeFileSync(sqlitePath, "fixture");
expect(inspectDoctorSqliteNoCow([sqlitePath]).paths).toEqual([sqlitePath]);

View file

@ -8,6 +8,7 @@ import { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/host/
import { requireDirectorySync, syncDirectory } from "../infra/directory-durability.js";
import { copyFileHandle, sameFileMutationFingerprint } from "../infra/file-descriptor.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import { retireSqliteDatabaseAdmissionForPath } from "../infra/sqlite-database-admission.js";
import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js";
import { createVerifiedSqliteSnapshot } from "../infra/sqlite-snapshot.js";
import { isSqlitePathOnBtrfs, setSqliteDirectoryNoCow } from "../infra/sqlite-wal-filesystem.js";
@ -307,6 +308,10 @@ export async function repairDoctorSqliteNoCow(params: {
.filter((pathname) => !sourceLinks.has(pathname))
.map((pathname) => [pathname, readAcl(pathname)]),
);
for (const pathname of sqlitePaths) {
params.assertCurrent();
retireSqliteDatabaseAdmissionForPath(pathname, { requireSoleDescriptor: true });
}
// fuser follows links, so inspect only regular files owned by this tree.
assertNoOpenFiles(regularPaths);
const size = [...inventory.values()].reduce((total, stat) => total + stat.size, 0n);
@ -401,6 +406,10 @@ export async function repairDoctorSqliteNoCow(params: {
const observedFiles = readStoreEntries(directory).map((entry) =>
path.join(entry.parentPath, entry.name),
);
for (const pathname of sqlitePaths) {
params.assertCurrent();
retireSqliteDatabaseAdmissionForPath(pathname, { requireSoleDescriptor: true });
}
assertNoOpenFiles(observedFiles.filter((pathname) => fs.lstatSync(pathname).isFile()));
// A file can appear while fuser checks the previously observed inventory.
const currentIdentity = fs.statSync(directory, { bigint: true });

View file

@ -200,7 +200,7 @@ function loadConfiguredAgentAuthStores(
const expectedAgentIds = expectedAgentIdsByDir.get(mainAgentDir);
const owner =
availability === "present"
? inspectOpenClawAgentDatabaseOwner(sharedDatabasePath)
? inspectOpenClawAgentDatabaseOwner(sharedDatabasePath, { revalidateSchema: true })
: undefined;
if (
availability === "unreadable" ||
@ -237,7 +237,9 @@ function loadConfiguredAgentAuthStores(
return { status: "blocked", warnings: [INVALID_SQLITE_STORE_WARNING] };
}
const owner =
availability === "present" ? inspectOpenClawAgentDatabaseOwner(databasePath) : undefined;
availability === "present"
? inspectOpenClawAgentDatabaseOwner(databasePath, { revalidateSchema: true })
: undefined;
if (
owner?.status === "unreadable" ||
(expectedAgentIds && owner?.status === "owned" && !expectedAgentIds.has(owner.agentId))
@ -297,7 +299,7 @@ function loadConfiguredAgentAuthStores(
if (availability === "unreadable") {
return { status: "blocked", warnings: [INVALID_SQLITE_STORE_WARNING] };
}
const owner = inspectOpenClawAgentDatabaseOwner(databasePath);
const owner = inspectOpenClawAgentDatabaseOwner(databasePath, { revalidateSchema: true });
if (owner.status !== "owned" || !owners.has(owner.agentId)) {
return { status: "blocked", warnings: [INVALID_SQLITE_STORE_WARNING] };
}

View file

@ -5,6 +5,10 @@ import { registerConfigCli } from "../cli/config-cli.js";
import { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js";
import * as schemas from "../plugins/schema-validator.js";
import { defaultRuntime } from "../runtime.js";
import {
closeOpenClawStateDatabaseAsync,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import * as metadata from "./io.plugin-metadata.js";
import type { OpenClawConfig } from "./types.js";
@ -55,6 +59,30 @@ async function validateThroughCli(
return { output: output.at(-1), schemaValidation };
}
it("validates existing state through a private read-only snapshot", async () => {
const output: unknown[] = [];
vi.spyOn(defaultRuntime, "writeJson").mockImplementation((value) => {
output.push(value);
});
vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
await withOpenClawTestState({ prefix: "config-validation-existing-state-" }, async (state) => {
await state.writeConfig({ gateway: { mode: "local" }, plugins: { enabled: false } });
const before = fs.readFileSync(state.configPath, "utf8");
openOpenClawStateDatabase({ env: state.env });
await closeOpenClawStateDatabaseAsync();
const program = new Command();
program.exitOverride();
registerConfigCli(program);
try {
await program.parseAsync(["config", "validate", "--json"], { from: "user" });
} catch (error) {
expect(error).toMatchObject({ name: "ExitError", code: 1 });
}
expect(output.at(-1)).toMatchObject({ valid: true });
expect(fs.readFileSync(state.configPath, "utf8")).toBe(before);
});
});
it.each([
{ rootPath: "/synthetic", valid: true, calls: 1 },
{ rootPath: "~/synthetic", valid: false, calls: 2 },

View file

@ -252,7 +252,10 @@ describe("config health-state warnings", () => {
it("propagates audit migration required from health writes and config snapshots", async () => {
const deps = createHealthDeps();
const { path: databasePath } = openOpenClawStateDatabase(deps);
await closeOpenClawStateDatabaseAsync();
closeOpenClawStateDatabaseForTest();
fs.renameSync(databasePath, `${databasePath}.seed`);
fs.copyFileSync(`${databasePath}.seed`, databasePath);
const db = new DatabaseSync(databasePath);
db.exec(`
DROP TABLE audit_events;

View file

@ -5,7 +5,7 @@ import type {
} from "../../gateway/session-transcript-read.types.js";
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
import { pruneMapToMaxSize } from "../../infra/map-size.js";
import { hasSqlitePostCommitScope } from "../../infra/sqlite-post-commit.js";
import { hasUncommittedSqliteWrites } from "../../infra/sqlite-schema-facts.js";
import {
resolveHistoryAnchorPageRange,
resolveTranscriptPageEnd,
@ -359,7 +359,7 @@ export function readRecentSessionTranscriptHistoryEventsFromProjection(
);
if (
!projection.generation ||
hasSqlitePostCommitScope(projection.database.db) ||
hasUncommittedSqliteWrites(projection.database.db) ||
projection.database.db.location() === null ||
options.expectedReadWindow ||
resolveSessionTranscriptReadFence(projection.resolved)

View file

@ -229,6 +229,8 @@ export function prepareSessionEntryMaintenanceInDatabase(
},
};
}
// Selected victims have not changed yet; their future age hint is not committed.
invalidateSessionEntryMaintenanceAgeFact(reader.db);
const readInputs = (database: Pick<OpenClawAgentDatabase, "db">) => {
const db = getSessionKysely(database.db);
const rows = executeSqliteQuerySync(

View file

@ -228,8 +228,6 @@ export function readSessionMaintenanceInWorker(
readPreservation(input),
);
if (prepared.kind === "write") {
// Selected victims have not changed yet; their future age hint is not committed.
invalidateSessionEntryMaintenanceAgeFact(database.db);
return {
kind:
plan.kind === "maintenance-plan"

View file

@ -412,7 +412,10 @@ export class SqliteReclamationWorker {
validationOwner: params.validationOwner,
readOpeningValidation: params.readOpeningValidation,
dispatch: () =>
worker.postMessage(params.request(operationId, coordination), [...params.transferList]),
worker.postMessage(params.request(operationId, coordination), [
...params.transferList,
...(coordination.databaseAdmission ? [coordination.databaseAdmission] : []),
]),
}).then(
(value) => ({ value }),
(error: unknown) => {
@ -422,6 +425,7 @@ export class SqliteReclamationWorker {
throw error;
},
),
params.assertCurrent,
)
.catch((error: unknown) => {
this.failure ??= toStringifiedError(error);

View file

@ -8,7 +8,7 @@ import {
iterateSqliteQuerySync,
} from "../../infra/kysely-sync.js";
import { pruneMapToMaxSize } from "../../infra/map-size.js";
import { hasSqlitePostCommitScope } from "../../infra/sqlite-post-commit.js";
import { hasUncommittedSqliteWrites } from "../../infra/sqlite-schema-facts.js";
import {
iterateUnindexedActiveTranscriptNavigation,
iterateUnindexedTranscriptNavigation,
@ -100,7 +100,7 @@ export function readUnindexedHistoryControls(
: snapshot.rows.filter((row) => row.event_seq <= coveredThrough);
}
const key = `${projection.database.path}\0${projection.resolved.sessionId}\0unindexed-controls`;
const cacheable = !hasSqlitePostCommitScope(projection.database.db);
const cacheable = !hasUncommittedSqliteWrites(projection.database.db);
const cached = cacheable ? resetMessageWindowCache.get(key) : undefined;
const reusable =
cached?.database === projection.database.db &&
@ -398,7 +398,7 @@ export function resolveTranscriptBoundaryWindow(
beforeRawSeq?: number,
): ResetMessageWindow | null {
// Current-turn bounds and uncommitted writes need their own window.
if (beforeRawSeq !== undefined || hasSqlitePostCommitScope(projection.database.db)) {
if (beforeRawSeq !== undefined || hasUncommittedSqliteWrites(projection.database.db)) {
return findLatestResetMessageWindow(projection, scope, beforeRawSeq);
}
const key = `${projection.database.path}\0${projection.resolved.sessionId}\0${scope}`;

View file

@ -1,16 +1,22 @@
import { threadId, type MessagePort } from "node:worker_threads";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveIdentityPathViaExistingAncestorSync } from "../../infra/boundary-path.js";
import { assertStateDatabaseAccessAllowed } from "../../infra/gateway-state-owner.js";
import { withSqliteDatabaseAdmissionExchange } from "../../infra/sqlite-database-admission.js";
import { retainSqliteWriteAdmissionService } from "../../infra/sqlite-transaction.js";
import { assertExistingDatabaseIdentity } from "../../infra/sqlite-worker-identity.js";
import {
createSqliteWorkerOperationAdmission,
exchangeSqliteDatabaseAdmissions,
requestSqliteWorkerOperationAdmission,
withSqliteWorkerOperationAdmission,
} from "../../infra/sqlite-worker-operation-admission.js";
import type { SqliteWorkerStateContext } from "../../infra/sqlite-worker-state-context.js";
import type { OpenClawAgentDatabaseOptions } from "../../state/openclaw-agent-db.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import {
resolveOpenClawStateSqlitePath,
resolveQuarantineStorePath,
} from "../../state/openclaw-state-db.paths.js";
import type { OpenClawStateWorkerContext } from "../../state/openclaw-state-worker-context.types.js";
import {
sqliteMutationWorkerThreadId,
@ -22,6 +28,7 @@ export type SqliteMutationWorkerCoordination = {
actorId: string;
databasePath: string;
stateContext: SqliteWorkerStateContext;
databaseAdmission?: MessagePort;
reconciliation?: { identity: string; admission: MessagePort };
};
@ -31,15 +38,23 @@ export async function withSqliteMutationWorkerCoordination<T>(
transport: SqliteMutationWorkerTransport,
operationId: number,
run: (coordination: SqliteMutationWorkerCoordination) => Promise<T>,
assertRequestCurrent?: () => void,
): Promise<T> {
const worker = transport.channel;
const actorId = `${sqliteMutationWorkerThreadId(transport)}:${operationId}`;
const preparingError = () => {};
worker.on("error", preparingError);
try {
return await withSqliteWorkerLifecycleCoordination(context, actorId, run, async () => {
await terminateSqliteMutationWorker(transport);
});
return await withSqliteWorkerLifecycleCoordination(
context,
actorId,
run,
async () => {
await terminateSqliteMutationWorker(transport);
},
"retained",
assertRequestCurrent,
);
} finally {
worker.off("error", preparingError);
}
@ -52,6 +67,7 @@ export async function withSqliteWorkerLifecycleCoordination<T>(
run: (coordination: SqliteMutationWorkerCoordination) => Promise<T>,
settleFailure: () => Promise<void>,
mode: "retained" | "reconciliation" = "retained",
assertRequestCurrent?: () => void,
): Promise<T> {
const identity = context.admission.identity.key;
let opened = false;
@ -79,7 +95,45 @@ export async function withSqliteWorkerLifecycleCoordination<T>(
}
opened ||= facts.phase === "open";
})
: undefined;
: assertRequestCurrent
? createSqliteWorkerOperationAdmission(() => {
throw new Error("SQLite mutation file admission does not grant transaction authority");
})
: undefined;
if (admission && assertRequestCurrent) {
admission.bindDatabaseAuthority({
databasePath: context.admission.databasePath,
assertRequest: assertRequestCurrent,
assertAccess() {
context.admission.assertCurrent();
// First creation stays path-bound until the native lease publishes its file identity.
const current = context.admission.identity;
if (current.key.startsWith("file:")) {
assertExistingDatabaseIdentity(
context.admission.databasePath,
current.key,
current.birthtime,
);
}
context.maintenanceScope?.assertAdmission();
},
assertCreate(location) {
// Raw agent admission may create shared state and its first integrity receipt.
if (
location !== resolveIdentityPathViaExistingAncestorSync(context.admission.databasePath) &&
location !==
resolveIdentityPathViaExistingAncestorSync(
resolveQuarantineStorePath(context.environment),
)
) {
throw new Error("SQLite mutation creation target differs from its captured companions");
}
},
acquireSchema() {
throw new Error("SQLite mutation file admission does not grant schema maintenance");
},
});
}
const releaseService = admission
? retainSqliteWriteAdmissionService([context.admission.databasePath], () => admission.service())
: undefined;
@ -88,7 +142,11 @@ export async function withSqliteWorkerLifecycleCoordination<T>(
actorId,
databasePath: context.admission.databasePath,
stateContext: { environment: context.environment },
...(admission ? { reconciliation: { identity, admission: admission.port } } : {}),
...(admission
? mode === "reconciliation"
? { reconciliation: { identity, admission: admission.port } }
: { databaseAdmission: admission.port }
: {}),
});
} catch (error) {
try {
@ -124,10 +182,31 @@ export async function runWithSqliteMutationWorkerCoordination<
) {
throw new Error("SQLite mutation Worker shared-state owner changed");
}
return await run({
...options,
env: { ...options.env, ...coordination.stateContext.environment },
});
const execute = () =>
run({
...options,
env: { ...options.env, ...coordination.stateContext.environment },
});
const admission = coordination.databaseAdmission;
if (!admission) {
return await execute();
}
let active = true;
try {
return await withSqliteDatabaseAdmissionExchange((facts, location, create) => {
if (!active) {
throw new Error("SQLite mutation file admission outlived its request");
}
if (create) {
// First-open creation can run inside this worker's live schema lease.
assertStateDatabaseAccessAllowed(coordination.databasePath);
}
return exchangeSqliteDatabaseAdmissions(admission, facts, location, create);
}, execute);
} finally {
active = false;
admission.close();
}
}
/** Reconciliation retains its native handles and durable agent lease between grants. */

View file

@ -1,6 +1,6 @@
import { spawnSync } from "node:child_process";
import { renameSync } from "node:fs";
import { DatabaseSync, StatementSync } from "node:sqlite";
import { backup, DatabaseSync, StatementSync } from "node:sqlite";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { afterEach, expect, it, vi } from "vitest";
import {
@ -8,7 +8,10 @@ import {
resolveRuntimeWorkerUrl,
} from "../../infra/runtime-worker-url.js";
import { createDeferredCore } from "../../shared/deferred.js";
import { closeOpenClawAgentDatabases } from "../../state/openclaw-agent-db-lifecycle.js";
import {
closeOpenClawAgentDatabaseByPathAsync,
closeOpenClawAgentDatabases,
} from "../../state/openclaw-agent-db-lifecycle.js";
import {
hasOpenClawAgentCanonicalValidation,
invalidateOpenClawAgentDatabaseValidation,
@ -107,13 +110,16 @@ it.each(["pending rows", "revoked receipt"] as const)(
},
);
it("refuses drifted triggers at a new canonical admission", async () => {
it("refuses replacement schema drift before reusing a warm canonical readiness receipt", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const { options, database } = seedPendingRows(0);
expect(hasOpenClawAgentCanonicalValidation(database)).toBe(true);
expect(hasPendingCanonicalSessionValidation(database)).toBe(false);
await certifySessionCanonicalValidationPending(options);
const peer = new DatabaseSync(database.path);
const replacement = `${database.path}.replacement`;
await backup(database.db, replacement);
await closeOpenClawAgentDatabaseByPathAsync(database.path);
const peer = new DatabaseSync(replacement);
try {
peer.exec(
"CREATE TRIGGER unexpected_session_trigger AFTER INSERT ON session_nodes BEGIN SELECT 1; END",
@ -121,9 +127,9 @@ it("refuses drifted triggers at a new canonical admission", async () => {
} finally {
peer.close();
}
invalidateOpenClawAgentDatabaseValidation(database.path);
renameSync(replacement, database.path);
await expect(certifySessionCanonicalValidationPending(options)).rejects.toThrow(
/unexpected trigger unexpected_session_trigger.*openclaw doctor --fix/u,
/unexpected_session_trigger.*openclaw doctor --fix/u,
);
});
});

View file

@ -97,8 +97,7 @@ export async function createSessionColdStorageFixture(
}, options);
const database = () => openOpenClawAgentDatabase(options).db;
const snapshot = () => {
const db = database();
const snapshot = (db = database()) => {
return {
events: executeSqliteQuerySync(
db,

View file

@ -9,6 +9,7 @@ import { observeHostDataSql } from "../../../test/helpers/sqlite-statement-execu
import { createTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { clearAgentRunContext, registerAgentRunContext } from "../../infra/agent-run-registry.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import { openNodeSqliteDatabase } from "../../infra/node-sqlite.js";
import { flushLogger, setLoggerOverride } from "../../logging/logger.js";
import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js";
import { sessionChanges } from "../../sessions/session-row-changes.js";
@ -694,28 +695,29 @@ describe("cold transcript storage workers", () => {
}
await closeOpenClawAgentDatabaseByPathAsync(fixture.options.path);
closeOpenClawAgentDatabasesForTest();
const changes = vi.fn(() =>
Boolean(readSessionColdTranscript(fixture.database(), historicalId)),
);
let inspection: ReturnType<typeof openNodeSqliteDatabase> | undefined;
// The committed notification must not re-register a writable database while observing it.
const readOnly = () =>
(inspection ??= openNodeSqliteDatabase(fixture.options.path, { readOnly: true }));
const changes = vi.fn(() => Boolean(readSessionColdTranscript(readOnly(), historicalId)));
const unsubscribe = sessionChanges.subscribe(changes);
try {
await restoreSessionColdTranscript(fixture.scope);
expect(fixture.snapshot()).toEqual(fixture.original);
expect(fixture.snapshot(readOnly())).toEqual(fixture.original);
expect(
fixture
.database()
readOnly()
.prepare(`SELECT f.message_id FROM session_transcript_fts_rows m
JOIN session_transcript_fts f ON f.rowid=m.id AND f.session_id=m.session_id
WHERE m.session_id=? ORDER BY f.message_id`)
.all(historicalId),
).toEqual([{ message_id: "history-assistant" }, { message_id: "history-user" }]);
expect(readSessionColdTranscript(fixture.database(), historicalId)).toBeUndefined();
expect(fixture.database().prepare("PRAGMA quick_check").get()).toEqual({
expect(readSessionColdTranscript(readOnly(), historicalId)).toBeUndefined();
expect(readOnly().prepare("PRAGMA quick_check").get()).toEqual({
quick_check: "ok",
});
expect(fixture.database().prepare("PRAGMA foreign_key_check").all()).toEqual([]);
expect(readOnly().prepare("PRAGMA foreign_key_check").all()).toEqual([]);
await restoreSessionColdTranscript(fixture.scope);
expect(fixture.snapshot()).toEqual(fixture.original);
expect(fixture.snapshot(readOnly())).toEqual(fixture.original);
expect(changes).toHaveBeenCalledExactlyOnceWith({
storePath: fixture.scope.storePath,
sessionKey: fixture.scope.sessionKey,
@ -723,6 +725,7 @@ describe("cold transcript storage workers", () => {
expect(changes.mock.results[0]?.value).toBe(false);
} finally {
unsubscribe();
inspection?.close();
}
},
);

View file

@ -6,7 +6,7 @@ import {
trackSqliteStatementExecutions,
} from "../../../test/helpers/sqlite-statement-execution-counter.js";
import * as boardStore from "../../boards/sqlite-board-store.kernel.js";
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
import { openNodeSqliteDatabase, requireNodeSqlite } from "../../infra/node-sqlite.js";
import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js";
import { sessionChanges } from "../../sessions/session-row-changes.js";
import { runInDetachedAsyncContext } from "../../shared/detached-async-context.js";
@ -458,7 +458,7 @@ it.each([false, true])("reads row metadata (continuation: %s)", async (useContin
});
});
it("consumes admitted board absence for a cohort and observes first use and foreign DDL", async () => {
it("consumes admitted board absence for a cohort and observes first use and published DDL", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async ({ env }) => {
const database = openOpenClawAgentDatabase({ agentId: "main", env });
const sessionKeys = Array.from(
@ -471,7 +471,7 @@ it("consumes admitted board absence for a cohort and observes first use and fore
database.db.exec("DROP TABLE board_widgets; DROP TABLE board_tabs");
const target = { agentId: database.agentId, path: database.path };
await closeOpenClawAgentDatabaseByPathAsync(database.path, database.agentId);
const peer = new (requireNodeSqlite().DatabaseSync)(target.path);
const peer = openNodeSqliteDatabase(target.path);
const retained = new OpenClawAgentDatabaseReadOnlyScope();
try {
retained.run(target, () => {

View file

@ -1,6 +1,14 @@
import { mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
import {
mkdirSync,
realpathSync,
renameSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { backup, type DatabaseSync } from "node:sqlite";
import { expect, it, vi } from "vitest";
import {
isSessionNodePayloadSelect,
@ -186,14 +194,17 @@ it("resolves runtime targets through one fresh admitted reader", async () => {
queries.restore();
}
const peer = new (nodeSqlite.requireNodeSqlite().DatabaseSync)(database.path);
const replacement = `${database.path}.replacement`;
await backup(database.db, replacement);
await closeOpenClawAgentDatabaseByPathAsync(database.path);
const peer = new (nodeSqlite.requireNodeSqlite().DatabaseSync)(replacement);
try {
peer.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`);
await expect(read()).rejects.toThrow("newer schema version");
} finally {
peer.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION}`);
peer.close();
}
renameSync(replacement, database.path);
await expect(read()).rejects.toThrow("newer schema version");
});
});

View file

@ -3,6 +3,7 @@ import type {
WorkerTaskOptions,
WorkerTaskPoolOptions,
} from "../../infra/worker-task-pool.types.js";
import type { WorkerTaskContext } from "../../infra/worker-task-transport.js";
import type { SessionTranscriptDisplayDeltaResult } from "./session-accessor.sqlite-history-query.js";
import { SessionTranscriptColdError } from "./session-cold-storage-state.js";
import { SessionTranscriptProjectionUnavailableError } from "./session-transcript-projection-error.js";
@ -13,15 +14,9 @@ type Request = {
taskId: number;
interactive?: boolean;
nativeSections: SharedArrayBuffer;
taskContext: [string, string][];
taskContext: WorkerTaskContext;
};
type Resource = { close: () => Promise<void>; agentId?: string; revoke: () => void };
type QuarantineDatabase = {
isOpen: boolean;
exec: () => void;
prepare: () => { get: () => unknown };
close: () => void;
};
const observed = vi.hoisted(() => ({
receive: undefined as ((message: Request) => void) | undefined,
post: vi.fn<(message: unknown) => void>(),
@ -35,11 +30,6 @@ const observed = vi.hoisted(() => ({
deferredRun: undefined as
| ((prepare: () => unknown, options: { inputBytes?: number }) => Promise<unknown>)
| undefined,
quarantineRead: vi.fn<() => unknown>(),
quarantineClose: vi.fn<() => void>(),
quarantineOpen: vi.fn<() => QuarantineDatabase>(),
quarantinePaths: new Set<string>(),
hydrate: vi.fn<() => unknown>(),
rotate: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
unregister: vi.fn<() => void>(),
resources: [] as Resource[],
@ -142,16 +132,6 @@ vi.mock("../../state/openclaw-agent-db-readonly-scope.js", () => ({
}
},
}));
vi.mock("../../infra/node-sqlite.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/node-sqlite.js")>();
return {
...actual,
openNodeSqliteDatabase: (...args: Parameters<typeof actual.openNodeSqliteDatabase>) =>
observed.quarantinePaths.has(args[0])
? observed.quarantineOpen()
: actual.openNodeSqliteDatabase(...args),
};
});
// Keep the history fixture's fake pool out of the process-wide disk-scan singleton.
vi.mock("./disk-budget-runtime.js", () => ({
measureSessionPhysicalDiskUsage: () => {
@ -159,9 +139,6 @@ vi.mock("./disk-budget-runtime.js", () => ({
},
drainSessionDiskBudgetWorkers: async () => {},
}));
vi.mock("./session-transcript-hydration.worker.js", () => ({
streamSessionTranscriptHydration: observed.hydrate,
}));
vi.mock("./session-accessor.sqlite-exact-read.js", () => ({
loadSessionEntryReadOnlyInScope: () => observed.read(),
}));

View file

@ -2,10 +2,8 @@
import "./session-history-worker-errors.test-support.js";
import assert from "node:assert/strict";
import { channel } from "node:diagnostics_channel";
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { readChatHistoryDelta } from "../../gateway/server-methods/chat-history-delta.js";
import { decodeAgentDatabaseReaderRequest } from "../../infra/agent-database-readers.js";
import { WorkerTaskError } from "../../infra/worker-task-pool.js";
@ -18,7 +16,6 @@ import {
SessionCanonicalKeyMigrationRequiredError,
} from "./session-canonical-row.js";
import { readSessionHistoryPageInWorker } from "./session-history-worker-runtime.js";
import { prepareSessionTranscriptHydration } from "./session-transcript-hydration.js";
import {
historyLane,
rotateDatabaseWorkers,
@ -91,7 +88,7 @@ function installWorkerTransport() {
taskId: 7,
interactive: Boolean(options.onRequest),
nativeSections: new SharedArrayBuffer(4),
taskContext: [],
taskContext: { deletedAgentDatabaseFences: [], databaseAdmissions: [] },
});
const reply = await posted.promise;
assert(reply && typeof reply === "object" && "status" in reply);
@ -112,7 +109,6 @@ async function readThroughWorker() {
);
}
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
beforeEach(() => {
closeCapabilities.explicitSqliteCloseReleasesNativeResources = true;
// Synthetic targets keep discovery outside the error-transfer worker controls.
@ -129,26 +125,6 @@ beforeEach(() => {
observed.rotate.mockReset().mockResolvedValue(undefined);
observed.closeResources.mockReset().mockResolvedValue(undefined);
observed.unregister.mockReset();
observed.quarantinePaths.clear();
observed.quarantineRead.mockReset().mockReturnValue({ user_version: 0 });
observed.quarantineClose.mockReset();
observed.quarantineOpen.mockReset().mockImplementation(() => {
const database: ReturnType<typeof observed.quarantineOpen> = {
isOpen: true,
exec() {},
prepare: () => ({ get: observed.quarantineRead }),
close() {
observed.quarantineClose();
database.isOpen = false;
},
};
return database;
});
observed.hydrate.mockReset().mockReturnValue({
kind: "full",
eventCount: 0,
version: { generation: null, rawSeq: null, updatedAt: null },
});
});
afterEach(async () => {
observed.rotate.mockResolvedValue(undefined);
@ -875,106 +851,3 @@ it.each([
}
},
);
function hydrateThroughWorker() {
const root = tempDirs.make("openclaw-hydration-quarantine-cleanup-");
fs.mkdirSync(path.join(root, "state"));
const quarantinePath = path.join(root, "state", "openclaw-quarantine.sqlite");
observed.quarantinePaths.add(quarantinePath);
fs.writeFileSync(quarantinePath, "mock quarantine");
installWorkerTransport();
return prepareSessionTranscriptHydration({
agentId: "main",
sessionId: "quarantine-hydration",
sessionKey: "agent:main:quarantine-hydration",
storePath: path.join(root, "agents", "main", "agent", "openclaw-agent.sqlite"),
env: { OPENCLAW_STATE_DIR: root },
}).read();
}
it.each([
{ readFails: true, closeFails: false, retirementFails: false },
{ readFails: false, closeFails: true, retirementFails: false },
{ readFails: true, closeFails: true, retirementFails: false },
{ readFails: true, closeFails: true, retirementFails: true },
])(
"settles hydration quarantine (read=$readFails, close=$closeFails, retirement=$retirementFails)",
async ({ readFails, closeFails, retirementFails }) => {
const readFailure = new Error("quarantine metadata read failed");
const closeFailure = Object.assign(new Error("quarantine native close failed"), {
code: "ERR_SQLITE_ERROR",
errcode: 5,
});
const stopFailure = new Error("quarantine worker retirement failed");
if (readFails) {
observed.quarantineRead.mockImplementation(() => {
throw readFailure;
});
}
if (!closeFails) {
await expect(hydrateThroughWorker()).resolves.toMatchObject({
kind: "full",
snapshot: { events: [] },
});
expect(observed.quarantineClose).toHaveBeenCalledOnce();
expect(observed.hydrate).toHaveBeenCalledOnce();
expect(observed.rotate).not.toHaveBeenCalled();
return;
}
observed.quarantineClose.mockImplementation(() => {
throw closeFailure;
});
const entered = createDeferredCore();
const retirement = createDeferredCore();
observed.rotate.mockImplementation(() => {
entered.resolve();
return retirement.promise;
});
let settled = false;
const pending = hydrateThroughWorker()
.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
)
.finally(() => {
settled = true;
});
try {
expect(
await Promise.race([entered.promise.then(() => "retiring"), pending.then(() => "settled")]),
).toBe("retiring");
expect(settled).toBe(false);
expect(observed.quarantineOpen.mock.results[0]?.value.isOpen).toBe(true);
expect(observed.hydrate).not.toHaveBeenCalled();
expect(observed.unregister).not.toHaveBeenCalled();
if (retirementFails) {
retirement.reject(stopFailure);
} else {
retirement.resolve();
}
const result = await pending;
assert("error" in result);
const failure = result.error;
assert(failure instanceof AggregateError);
const quarantine = retirementFails ? failure.errors[0] : failure;
assert(quarantine instanceof AggregateError);
expect(quarantine.name).toBe("OpenClawQuarantineReadCleanupError");
expect(quarantine.errors).toMatchObject([
...(readFails ? [{ message: readFailure.message }] : []),
{ message: closeFailure.message, code: "ERR_SQLITE_ERROR", errcode: 5 },
]);
expect(quarantine.cause).toBe(quarantine.errors[0]);
expect(observed.rotate).toHaveBeenCalledOnce();
if (retirementFails) {
expect(failure.errors[1]).toBe(stopFailure);
expect(failure.cause).toBe(stopFailure);
expect(observed.unregister).not.toHaveBeenCalled();
} else {
expect(observed.unregister).toHaveBeenCalledOnce();
}
} finally {
retirement.resolve();
await pending;
}
},
);

View file

@ -26,14 +26,6 @@ export async function readSessionTranscriptHydrationRequest(
}
return result.value;
}
const { readOpenClawDatabaseQuarantineFailure } =
await import("../../state/openclaw-quarantine-store.js");
const quarantine = readOpenClawDatabaseQuarantineFailure("agent", request.database.path, {
env: request.target.env,
});
if (quarantine) {
throw quarantine;
}
if (request.kind === "latest-active-message") {
const { readLatestSessionTranscriptMessageEvent } =
await import("./session-accessor.sqlite-active-events.js");

View file

@ -4,8 +4,17 @@ import {
} from "../../infra/bun-sqlite-library.js";
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
import { resolveWorkerPoolSize } from "../../infra/worker-pool-sizing.js";
import {
UsageCostWorkerReplyError,
type UsageCostWorkerInput,
type UsageCostWorkerReply,
} from "../../infra/session-cost-usage-worker.types.js";
import {
resolveWorkerPoolSize,
SESSION_TRANSCRIPT_FOREGROUND_WORKERS,
} from "../../infra/worker-pool-sizing.js";
import { createOwnedWorkerTaskPool, WorkerTaskPool } from "../../infra/worker-task-pool.js";
import { assertOpenClawAgentWriterReleased } from "../../state/openclaw-agent-write-admission-state.js";
import type {
SessionHistoryWorkerInput,
SessionTranscriptWorkerInput,
@ -67,3 +76,110 @@ export function createSessionTranscriptHistoryPool(
generations.size > 0 && [...generations].every((entry) => entry.canCloseNativeResources),
};
}
function createUsageCostPool(kind: "read" | "refresh") {
return new WorkerTaskPool<UsageCostWorkerInput, UsageCostWorkerReply>({
workerUrl,
workerOptions: { resourceLimits: { maxOldGenerationSizeMb: 512 } },
// A retired task releases lane-wide database custody, which requires one native worker.
workerClass: kind === "refresh" ? "writer" : "singleton",
// Host writes can wait for a writer-held shared-compute reader; neither usage lane may hold its permit.
idleTimeoutMs: 0,
prepareWorker: () => {
ensureSqliteLibrarySelected();
return { options: {} };
},
validateResult(reply) {
if (!reply.ok) {
throw new UsageCostWorkerReplyError(reply.error);
}
},
});
}
export type SessionDatabaseWorkerLane = {
name: string;
pool: { rotate: () => Promise<void> };
nativeSequence: number;
retiredSequence: number;
pending: number;
idleTimer?: NodeJS.Timeout;
rotation?: Promise<void>;
};
function createDatabaseWorkerLane<Pool extends SessionDatabaseWorkerLane["pool"]>(
name: string,
pool: Pool,
): SessionDatabaseWorkerLane & { pool: Pool } {
return { name, pool, nativeSequence: 0, retiredSequence: 0, pending: 0 };
}
export function createSessionTranscriptWorkerLanes() {
const historyLane = createDatabaseWorkerLane(
"Session history",
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
);
// Search retains its reader while the host checks writable index readiness.
// It must never occupy the workers serving committed history and metadata.
const transcriptSearchLane = createDatabaseWorkerLane(
"Session transcript search",
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
);
// Keep list materialization independent of large history pages, with one extra reader per store.
const projectionLane = createDatabaseWorkerLane(
"Session projection",
createSessionTranscriptHistoryPool(),
);
// Full-store validation cannot yield its snapshot to a foreground history read.
const maintenanceLane = createDatabaseWorkerLane(
"Session maintenance",
createSessionTranscriptHistoryPool(),
);
// Writers retain FIFO admission through target discovery and cleanup. These reads
// cannot share a worker with history tasks that await a host-side database write.
const targetDiscoveryLane = createDatabaseWorkerLane(
"Session target discovery",
createSessionTranscriptHistoryPool(),
);
const costReadLane = createDatabaseWorkerLane("Session usage read", createUsageCostPool("read"));
const costRefreshLane = createDatabaseWorkerLane(
"Session usage refresh",
createUsageCostPool("refresh"),
);
const independentHistoryLanes = [
historyLane,
transcriptSearchLane,
projectionLane,
maintenanceLane,
];
// Install only in development: production dispatch/cleanup has no context check.
// Target discovery is reserved with the writer; its cold-read host callbacks
// reenter that reservation. Other pools can wait on an independent writer.
if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
for (const lane of [...independentHistoryLanes, costReadLane, costRefreshLane]) {
const rotate = lane.pool.rotate.bind(lane.pool);
lane.pool.rotate = () => {
assertOpenClawAgentWriterReleased(`drain the ${lane.name} reader pool`);
return rotate();
};
}
for (const lane of independentHistoryLanes) {
const close = lane.pool.closeResources;
lane.pool.closeResources = (key) => {
assertOpenClawAgentWriterReleased(`close the ${lane.name} reader pool`);
return close(key);
};
}
}
return {
historyLane,
transcriptSearchLane,
projectionLane,
maintenanceLane,
targetDiscoveryLane,
costReadLane,
costRefreshLane,
};
}

View file

@ -1,3 +1,5 @@
import path from "node:path";
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
import {
resolveWorkerPoolSize,
SESSION_TRANSCRIPT_FOREGROUND_WORKERS,
@ -7,6 +9,10 @@ import type { readSessionTranscriptModelContext } from "./session-accessor.sqlit
import type { SessionTranscriptRuntimeTarget } from "./session-accessor.types.js";
import type { SessionContextMessagesWorkerInput } from "./session-history-read.types.js";
import { unwrapSessionTranscriptWorkerReply } from "./session-history-worker-errors.js";
import {
listSqliteTargetCandidatePathsForSessionStorePath,
resolveUnsuffixedSqliteTargetFromSessionStorePath,
} from "./session-sqlite-target-paths.js";
import { resolveSessionTranscriptReadFence } from "./session-transcript-read-fence.js";
import { createSessionTranscriptReadPool } from "./session-transcript-read-pools.js";
import type {
@ -38,9 +44,13 @@ export async function readSessionTranscriptModelContextInWorker(
): Promise<ReturnType<typeof readSessionTranscriptModelContext>> {
signal?.throwIfAborted();
const value = unwrapSessionTranscriptWorkerReply(
await modelContextReads.run(
{ kind: "model-context", target, admission, through, limits, expectedIdentity },
{ timeoutMs: 60_000, signal },
await runWithSqliteDatabaseAdmissionTurn(
[resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath).path],
() =>
modelContextReads.run(
{ kind: "model-context", target, admission, through, limits, expectedIdentity },
{ timeoutMs: 60_000, signal },
),
),
);
if (!("events" in value)) {
@ -54,10 +64,16 @@ export async function resolveSessionSqliteTargetInWorker(
signal?: AbortSignal,
) {
signal?.throwIfAborted();
const target = resolveUnsuffixedSqliteTargetFromSessionStorePath(input.storePath);
const value = unwrapSessionTranscriptWorkerReply(
await modelContextReads.run(
{ kind: "sqlite-target", ...input },
{ inputBytes: JSON.stringify(input).length * 2, timeoutMs: 60_000, signal },
await runWithSqliteDatabaseAdmissionTurn(
listSqliteTargetCandidatePathsForSessionStorePath(input.storePath),
() =>
modelContextReads.run(
{ kind: "sqlite-target", ...input },
{ inputBytes: JSON.stringify(input).length * 2, timeoutMs: 60_000, signal },
),
target.agentId || target.shared ? [] : [path.dirname(target.path)],
),
);
if (!("target" in value)) {
@ -74,9 +90,13 @@ export async function readSessionTranscriptContextMessagesInWorker(
) {
signal?.throwIfAborted();
const value = unwrapSessionTranscriptWorkerReply(
await modelContextReads.run(
{ kind: "context-messages", target, admission, expectedIdentity },
{ timeoutMs: 60_000, signal },
await runWithSqliteDatabaseAdmissionTurn(
[resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath).path],
() =>
modelContextReads.run(
{ kind: "context-messages", target, admission, expectedIdentity },
{ timeoutMs: 60_000, signal },
),
),
);
if (!("messages" in value)) {
@ -92,24 +112,28 @@ export async function prepareSessionEntryInWorker(
) {
const receipt = resolveSessionTranscriptReadFence(options);
const result = unwrapSessionTranscriptWorkerReply<"session-entry" | "session-reset-recall">(
await sessionEntries.run(
{
kind: "session-entry",
absPath,
options,
redaction,
...(receipt ? { admission: { ...receipt } } : {}),
},
{
inputBytes:
2 *
(absPath.length +
options.agentId.length +
options.sessionId.length +
options.storePath.length +
(options.sessionKey?.length ?? 0) +
redaction.registeredSecretValues.reduce((bytes, value) => bytes + value.length, 0)),
},
await runWithSqliteDatabaseAdmissionTurn(
[resolveUnsuffixedSqliteTargetFromSessionStorePath(options.storePath).path],
() =>
sessionEntries.run(
{
kind: "session-entry",
absPath,
options,
redaction,
...(receipt ? { admission: { ...receipt } } : {}),
},
{
inputBytes:
2 *
(absPath.length +
options.agentId.length +
options.sessionId.length +
options.storePath.length +
(options.sessionKey?.length ?? 0) +
redaction.registeredSecretValues.reduce((bytes, value) => bytes + value.length, 0)),
},
),
),
);
if (!("entry" in result)) {
@ -123,13 +147,17 @@ export async function readSessionResetRecallCutoffInWorker(
) {
const receipt = resolveSessionTranscriptReadFence(scope);
const result = unwrapSessionTranscriptWorkerReply<"session-entry" | "session-reset-recall">(
await sessionEntries.run(
{
kind: "session-reset-recall",
scope,
...(receipt ? { admission: { ...receipt } } : {}),
},
{ inputBytes: JSON.stringify(scope).length * 2 },
await runWithSqliteDatabaseAdmissionTurn(
[resolveUnsuffixedSqliteTargetFromSessionStorePath(scope.storePath).path],
() =>
sessionEntries.run(
{
kind: "session-reset-recall",
scope,
...(receipt ? { admission: { ...receipt } } : {}),
},
{ inputBytes: JSON.stringify(scope).length * 2 },
),
),
);
if (!("cutoff" in result)) {

View file

@ -4,6 +4,7 @@ import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"
import type { Result } from "@openclaw/normalization-core/result";
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
import { readDatabasePathIdentitySync } from "../../infra/sqlite-worker-identity.js";
import { resolveWorkerPoolSize } from "../../infra/worker-pool-sizing.js";
import { WorkerTaskPool } from "../../infra/worker-task-pool.js";
@ -453,23 +454,27 @@ async function startReconcileWorkerTask(
(input.mode === "disk" ? 2 * input.agentId.length : 0));
let poolCompletion: Promise<void> | undefined;
const execute = async (coordination?: SqliteMutationWorkerCoordination) => {
poolCompletion = pool.run(
{
input,
port: port2,
coordination,
sourceIdentity,
},
{
inputBytes,
transferList: (task) => [
task.port,
...(task.coordination?.reconciliation
? [task.coordination.reconciliation.admission]
: []),
],
signal: controller.signal,
},
poolCompletion = runWithSqliteDatabaseAdmissionTurn(
input.mode === "disk" ? [input.path] : [],
() =>
pool.run(
{
input,
port: port2,
coordination,
sourceIdentity,
},
{
inputBytes,
transferList: (task) => [
task.port,
...(task.coordination?.reconciliation
? [task.coordination.reconciliation.admission]
: []),
],
signal: controller.signal,
},
),
);
try {
await poolCompletion;

View file

@ -1,17 +1,41 @@
import { appendFileSync } from "node:fs";
import { workerData } from "node:worker_threads";
const { register } = await import(workerData.sourceLoaderUrl);
register();
const { requireNodeSqlite, resolveNodeSqliteLocation } = await import("../../infra/node-sqlite.ts");
const sqlite = requireNodeSqlite();
const target = resolveNodeSqliteLocation(workerData.databasePath);
const target = workerData.databasePath && resolveNodeSqliteLocation(workerData.databasePath);
// oxlint-disable-next-line typescript/unbound-method -- The fault wrapper calls the captured method with its database receiver.
const close = sqlite.DatabaseSync.prototype.close;
let failed = false;
sqlite.DatabaseSync.prototype.close = function () {
if (!failed && this.location() === target) {
if (target && !failed && this.location() === target) {
failed = true;
throw new Error("Synthetic native shared-state close failure");
}
return close.call(this);
};
if (workerData.schemaTrace) {
const paths = new Set(workerData.schemaTrace.databasePaths.map(resolveNodeSqliteLocation));
const statements = new WeakMap();
// oxlint-disable-next-line typescript/unbound-method -- The trace wrapper supplies the intercepted database receiver.
const prepare = sqlite.DatabaseSync.prototype.prepare;
sqlite.DatabaseSync.prototype.prepare = function (sql) {
const statement = prepare.call(this, sql);
if (paths.has(this.location())) {
statements.set(statement, sql);
}
return statement;
};
for (const method of ["get", "all", "iterate"]) {
const execute = sqlite.StatementSync.prototype[method];
sqlite.StatementSync.prototype[method] = function (...args) {
const sql = statements.get(this);
if (sql && /PRAGMA\s+(?:schema_version|user_version)\b/i.test(sql)) {
appendFileSync(workerData.schemaTrace.path, `${JSON.stringify(sql)}\n`);
}
return Reflect.apply(execute, this, args);
};
}
}
await import("./session-transcript-reconcile.worker.ts");

View file

@ -1,15 +1,16 @@
import { execFile } from "node:child_process";
import { once } from "node:events";
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { MessageChannel, type MessagePort } from "node:worker_threads";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import { acquireGatewayStateOwner } from "../../infra/gateway-state-owner.js";
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
import { openNodeSqliteDatabase, requireNodeSqlite } from "../../infra/node-sqlite.js";
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
import { getAdmittedSqliteSchemaFacts } from "../../infra/sqlite-schema-facts.js";
import { readDatabasePathIdentitySync } from "../../infra/sqlite-worker-identity.js";
import * as admissionOwner from "../../infra/sqlite-worker-operation-admission.js";
import { sqliteWorkerOwnerProbe as probe } from "../../infra/sqlite-worker-owner-probe.test-support.js";
@ -38,17 +39,22 @@ import type {
} from "./session-transcript-reconcile.worker.js";
type Pool = WorkerTaskPool<SessionTranscriptReconcileWorkerTask, void>;
function createPool(failNativeCloseAt?: string): Pool {
function createPool(
failNativeCloseAt?: string,
schemaTrace?: { path: string; databasePaths: string[] },
): Pool {
return new WorkerTaskPool<SessionTranscriptReconcileWorkerTask, void>({
workerUrl: failNativeCloseAt
? new URL("./session-transcript-reconcile.close-failure.test-support.mjs", import.meta.url)
: resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscriptReconcile),
...(failNativeCloseAt
workerUrl:
failNativeCloseAt || schemaTrace
? new URL("./session-transcript-reconcile.close-failure.test-support.mjs", import.meta.url)
: resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscriptReconcile),
...(failNativeCloseAt || schemaTrace
? {
workerOptions: {
workerData: {
sourceLoaderUrl: import.meta.resolve("tsx/esm/api"),
databasePath: failNativeCloseAt,
schemaTrace,
},
},
}
@ -276,8 +282,8 @@ describe("reconciliation cleanup transport native custody", () => {
});
});
it.each(["exclude", "schema", "version"] as const)(
"preserves deletion and schema checks across the phase yield against foreign %s",
it.each(["exclude", "schema", "version", "own-schema"] as const)(
"keeps phase authority and admitted format across %s changes without version polling",
async (operation) => {
await withOpenClawTestState(
{ scenario: "external-service", label: "reconcile-phase-yield" },
@ -287,10 +293,44 @@ it.each(["exclude", "schema", "version"] as const)(
closeOpenClawAgentDatabaseByPath(options.path);
closeOpenClawStateDatabaseForTest();
const context = captureOpenClawStateWorkerContext();
const pool = createPool();
const tracePath = state.path("reconcile-schema-sql.jsonl");
const pool = createPool(undefined, {
path: tracePath,
databasePaths: [context.admission.databasePath, options.path],
});
const ready = createDeferred<MessagePort>();
const leaseId = `phase-yield-${operation}`;
const observation = observe(context);
let closeGranted = false;
const createAdmission = admissionOwner.createSqliteWorkerOperationAdmission;
const admissions =
operation === "own-schema"
? vi
.spyOn(admissionOwner, "createSqliteWorkerOperationAdmission")
.mockImplementation((admit, attachment) =>
createAdmission(
(request, grant) =>
admit(request, (beforeRelease) => {
const granted = grant(beforeRelease);
const facts = request.facts;
if (
granted &&
request.stage === "prepare" &&
typeof facts === "object" &&
facts !== null &&
"kind" in facts &&
facts.kind === "transcript-reconciliation" &&
"phase" in facts &&
facts.phase === "close"
) {
closeGranted = true;
}
return granted;
}),
attachment,
),
)
: undefined;
let parent: MessagePort | undefined;
let completed = false;
const task = releaseInRealWorker(pool, context, leaseId, options.path, {
@ -313,38 +353,59 @@ it.each(["exclude", "schema", "version"] as const)(
throw new Error("Reconciliation settled before the phase-yield witness");
}),
]);
const child = await promisify(execFile)(process.execPath, [
fileURLToPath(
new URL(
"./session-transcript-reconcile.foreign-owner.test-support.mjs",
import.meta.url,
),
),
JSON.stringify({
operation,
statePath: context.admission.databasePath,
agentPath: options.path,
environment: context.environment,
sourceLoaderUrl: import.meta.resolve("tsx/esm/api"),
}),
]);
const verdict: unknown = JSON.parse(child.stdout);
if (operation === "exclude") {
expect(verdict).toEqual({ agentCleanupRefused: true });
} else if (operation === "version") {
expect(verdict).toEqual({ changed: true, unchangedSchemaCookie: true });
if (operation === "own-schema") {
expect(observation.calls).toEqual([]);
const sibling = openNodeSqliteDatabase(options.path);
try {
const previousAdmission = getAdmittedSqliteSchemaFacts(sibling)?.admissionId;
expect(typeof previousAdmission).toBe("string");
sibling.exec("CREATE TABLE synthetic_schema_reentry_guard (value INTEGER)");
const publishedAdmission = getAdmittedSqliteSchemaFacts(sibling)?.admissionId;
expect(typeof publishedAdmission).toBe("string");
expect(publishedAdmission).not.toBe(previousAdmission);
} finally {
sibling.close();
}
// The synchronous fixture mutation is separate from both worker lifecycle phases.
observation.calls.length = 0;
} else {
expect(verdict).toEqual({ changed: true });
const child = await promisify(execFile)(process.execPath, [
fileURLToPath(
new URL(
"./session-transcript-reconcile.foreign-owner.test-support.mjs",
import.meta.url,
),
),
JSON.stringify({
operation,
statePath: context.admission.databasePath,
agentPath: options.path,
environment: context.environment,
sourceLoaderUrl: import.meta.resolve("tsx/esm/api"),
}),
]);
const verdict: unknown = JSON.parse(child.stdout);
if (operation === "exclude") {
expect(verdict).toEqual({ agentCleanupRefused: true });
} else if (operation === "version") {
expect(verdict).toEqual({ changed: true, unchangedSchemaCookie: true });
} else {
expect(verdict).toEqual({ changed: true });
}
}
parent.postMessage({ type: "release" }, []);
if (operation === "exclude") {
await expect(task).resolves.toContainEqual({ type: "lease-released" });
if (operation === "own-schema") {
// Native-phase failures retire the isolate instead of transporting its internal error.
await expect(task).rejects.toThrow("worker exited with code 1");
expect(closeGranted).toBe(true);
} else {
await expect(task).rejects.toThrow();
await expect(task).resolves.toContainEqual({ type: "lease-released" });
}
completed = true;
await pool.close();
expect(pool.getSnapshot().workers).toBe(0);
expect(observation.calls).toEqual([]);
expect(existsSync(tracePath) ? readFileSync(tracePath, "utf8") : "").toBe("");
} finally {
if (!completed) {
parent?.postMessage({ type: "release" }, []);
@ -352,11 +413,12 @@ it.each(["exclude", "schema", "version"] as const)(
await task.catch(() => {});
await pool.close();
observation.restore();
admissions?.mockRestore();
}
const lease = openOpenClawStateDatabase()
.db.prepare("SELECT lease_id FROM agent_database_leases WHERE lease_id = ?")
.get(leaseId);
if (operation !== "exclude") {
if (operation === "own-schema") {
expect(lease).toEqual({ lease_id: leaseId });
releaseOpenClawAgentDatabaseLease(leaseId);
} else {

View file

@ -1,7 +1,7 @@
/** Worker entrypoint for transcript parsing and active-branch resolution only. */
import { MessagePort } from "node:worker_threads";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readSqliteSchemaCookie } from "../../infra/sqlite-schema-contract.js";
import { getAdmittedSqliteSchemaFacts } from "../../infra/sqlite-schema-facts.js";
import { assertExistingDatabaseIdentity } from "../../infra/sqlite-worker-identity.js";
import { serveWorkerTasks } from "../../infra/worker-task-server.js";
import {
@ -365,9 +365,9 @@ async function run(
throw new Error("Transcript worker lost its admitted shared-state handle");
}
const shared = sharedBorrow.database;
const sharedSchema = readSqliteSchemaCookie(shared.db);
const agentSchema = readSqliteSchemaCookie(opened.database.db);
if (typeof sharedSchema !== "number" || typeof agentSchema !== "number") {
const sharedSchema = getAdmittedSqliteSchemaFacts(shared.db)?.admissionId;
const agentSchema = getAdmittedSqliteSchemaFacts(opened.database.db)?.admissionId;
if (!sharedSchema || !agentSchema) {
throw new Error("Transcript worker cannot retain its admitted schema identities");
}
// Reentry is a new native phase, never adoption of a schema changed while yielding.
@ -375,8 +375,8 @@ async function run(
sharedBorrow?.assertCurrent();
if (
!shared.db.isOpen ||
readSqliteSchemaCookie(shared.db) !== sharedSchema ||
readSqliteSchemaCookie(opened.database.db) !== agentSchema
getAdmittedSqliteSchemaFacts(shared.db)?.admissionId !== sharedSchema ||
getAdmittedSqliteSchemaFacts(opened.database.db)?.admissionId !== agentSchema
) {
throw new Error("Transcript worker schema changed between lifecycle phases");
}

View file

@ -65,8 +65,13 @@ vi.mock("node:worker_threads", async (importOriginal) => {
};
});
// Transport controls normally represent an admitted store; the cold-admission
// regression below exercises the real writer queue before such facts exist.
// Transport controls model settled native preparation and its published format facts;
// cold-admission regressions exercise the real writer queue before either exists.
vi.mock("../../infra/sqlite-database-admission.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../infra/sqlite-database-admission.js")>()),
hasSqliteDatabaseSchemaAdmissionForPath: () => observed.preparedDatabase,
}));
vi.mock("../../state/openclaw-agent-execution.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../state/openclaw-agent-execution.js")>()),
captureExistingOpenClawAgentDatabaseExecution: (options: { path: string }) => {

View file

@ -3,18 +3,14 @@ import path from "node:path";
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import { joinOwnedWorkerTasks } from "@openclaw/worker-runtime";
import { encodeAgentDatabaseReaderRequest } from "../../infra/agent-database-readers.js";
import { ensureSqliteLibrarySelected } from "../../infra/bun-sqlite-library.js";
import { runtimeProcessEntrypoints } from "../../infra/runtime-process-entrypoints.js";
import { resolveRuntimeWorkerUrl } from "../../infra/runtime-worker-url.js";
import {
UsageCostWorkerReplyError,
type UsageCostWorkerDatabase,
type UsageCostWorkerInput,
type UsageCostWorkerReply,
import type {
UsageCostWorkerDatabase,
UsageCostWorkerInput,
UsageCostWorkerReply,
} from "../../infra/session-cost-usage-worker.types.js";
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
import { SQLITE_IDLE_HANDLE_TTL_MS } from "../../infra/sqlite-handle-lifecycle.js";
import { SESSION_TRANSCRIPT_FOREGROUND_WORKERS } from "../../infra/worker-pool-sizing.js";
import { WorkerTaskError, WorkerTaskPool } from "../../infra/worker-task-pool.js";
import { WorkerTaskError, type WorkerTaskPool } from "../../infra/worker-task-pool.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { runInDetachedAsyncContext } from "../../shared/detached-async-context.js";
import type { OpenClawAgentDatabaseOptions } from "../../state/openclaw-agent-db-contract.js";
@ -24,7 +20,6 @@ import {
registerOpenClawAgentDatabaseReadCandidateResource,
} from "../../state/openclaw-agent-db-resources.js";
import { resolveOpenClawAgentSqlitePath } from "../../state/openclaw-agent-db.paths.js";
import { assertOpenClawAgentWriterReleased } from "../../state/openclaw-agent-write-admission-state.js";
import { runOutsideOpenClawDatabaseMaintenanceScope } from "../../state/openclaw-state-db-async-lifecycle.js";
import { registerOpenClawStateDatabaseAsyncResource } from "../../state/openclaw-state-db-cache.js";
import {
@ -43,42 +38,14 @@ import type {
SessionStoreTargetReadResult,
SessionStoreTargetInventoryResult,
} from "./session-store-target-inventory.js";
import { createSessionTranscriptHistoryPool } from "./session-transcript-read-pools.js";
import {
createSessionTranscriptWorkerLanes,
type createSessionTranscriptHistoryPool,
type SessionDatabaseWorkerLane,
} from "./session-transcript-read-pools.js";
import type { SessionHistoryWorkerInput } from "./session-transcript-worker.types.js";
import { captureSessionTranscriptStorageEnvironment } from "./transcript-target-binding.js";
const workerUrl = resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscript);
function createUsageCostPool(kind: "read" | "refresh") {
return new WorkerTaskPool<UsageCostWorkerInput, UsageCostWorkerReply>({
workerUrl,
workerOptions: { resourceLimits: { maxOldGenerationSizeMb: 512 } },
// A retired task releases lane-wide database custody, which requires one native worker.
workerClass: kind === "refresh" ? "writer" : "singleton",
// Host writes can wait for a writer-held shared-compute reader; neither usage lane may hold its permit.
idleTimeoutMs: 0,
prepareWorker: () => {
ensureSqliteLibrarySelected();
return { options: {} };
},
validateResult(reply) {
if (!reply.ok) {
throw new UsageCostWorkerReplyError(reply.error);
}
},
});
}
type SessionDatabaseWorkerLane = {
name: string;
pool: { rotate: () => Promise<void> };
nativeSequence: number;
retiredSequence: number;
pending: number;
idleTimer?: NodeJS.Timeout;
rotation?: Promise<void>;
};
export type SessionCostWorkerLane = SessionDatabaseWorkerLane & {
pool: WorkerTaskPool<UsageCostWorkerInput, UsageCostWorkerReply>;
};
@ -112,76 +79,25 @@ const historyDatabases = new Map<string, HistoryDatabaseResource>();
const historySetTimeout = setTimeout;
export const historyClearTimeout = clearTimeout;
let historyGeneration = 0;
function createDatabaseWorkerLane<Pool extends SessionDatabaseWorkerLane["pool"]>(
name: string,
pool: Pool,
): SessionDatabaseWorkerLane & { pool: Pool } {
return { name, pool, nativeSequence: 0, retiredSequence: 0, pending: 0 };
}
export const historyLane = createDatabaseWorkerLane(
"Session history",
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
);
// Search retains its reader while the host checks writable index readiness.
// It must never occupy the workers serving committed history and metadata.
export const transcriptSearchLane = createDatabaseWorkerLane(
"Session transcript search",
createSessionTranscriptHistoryPool(SESSION_TRANSCRIPT_FOREGROUND_WORKERS),
);
// Keep list materialization independent of large history pages, with one extra reader per store.
export const projectionLane = createDatabaseWorkerLane(
"Session projection",
createSessionTranscriptHistoryPool(),
);
// Full-store validation cannot yield its snapshot to a foreground history read.
export const maintenanceLane = createDatabaseWorkerLane(
"Session maintenance",
createSessionTranscriptHistoryPool(),
);
// Writers retain FIFO admission through target discovery and cleanup. These reads
// cannot share a worker with history tasks that await a host-side database write.
export const targetDiscoveryLane = createDatabaseWorkerLane(
"Session target discovery",
createSessionTranscriptHistoryPool(),
);
export const costReadLane = createDatabaseWorkerLane(
"Session usage read",
createUsageCostPool("read"),
);
export const costRefreshLane = createDatabaseWorkerLane(
"Session usage refresh",
createUsageCostPool("refresh"),
);
const independentHistoryLanes = [
export const {
historyLane,
transcriptSearchLane,
projectionLane,
maintenanceLane,
targetDiscoveryLane,
costReadLane,
costRefreshLane,
} = createSessionTranscriptWorkerLanes();
const historyWorkerLanes = [
historyLane,
transcriptSearchLane,
projectionLane,
maintenanceLane,
targetDiscoveryLane,
];
const historyWorkerLanes = [...independentHistoryLanes, targetDiscoveryLane];
const databaseWorkerLanes = [...historyWorkerLanes, costReadLane, costRefreshLane];
// Install only in development: production dispatch/cleanup has no context check.
// Target discovery is reserved with the writer; its cold-read host callbacks
// reenter that reservation. Other pools can wait on an independent writer.
if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
for (const lane of [...independentHistoryLanes, costReadLane, costRefreshLane]) {
const rotate = lane.pool.rotate.bind(lane.pool);
lane.pool.rotate = () => {
assertOpenClawAgentWriterReleased(`drain the ${lane.name} reader pool`);
return rotate();
};
}
for (const lane of independentHistoryLanes) {
const close = lane.pool.closeResources;
lane.pool.closeResources = (key) => {
assertOpenClawAgentWriterReleased(`close the ${lane.name} reader pool`);
return close(key);
};
}
}
const memoryPressure = channel("openclaw.memory.critical");
let pressureSubscribed = false;
@ -482,6 +398,12 @@ export async function withSessionHistoryWorkerReadCandidates<T>(
path: physicalPath,
...(scope ? { scope } : {}),
}));
const admissionPaths = capturedCandidates
.filter((candidate) => !candidate.scope)
.map((candidate) => candidate.physicalPath);
const admissionFamilies = capturedCandidates
.filter((candidate) => candidate.scope === "sibling-family")
.map((candidate) => path.dirname(candidate.physicalPath));
historyClearTimeout(lane.idleTimer);
lane.pending++;
refreshDatabaseWorkerPressureSubscription();
@ -633,14 +555,19 @@ export async function withSessionHistoryWorkerReadCandidates<T>(
env: captureSessionTranscriptStorageEnvironment(request.env),
candidates: capturedCandidates,
};
const reply = await lane.pool.run(
() => {
assertCurrent();
dispatched = true;
lane.nativeSequence++;
return { kind: "session-store-target", request: preparedRequest };
},
{ inputBytes: JSON.stringify(preparedRequest).length * 2, timeoutMs: 60_000 },
const reply = await runWithSqliteDatabaseAdmissionTurn(
admissionPaths,
() =>
lane.pool.run(
() => {
assertCurrent();
dispatched = true;
lane.nativeSequence++;
return { kind: "session-store-target", request: preparedRequest };
},
{ inputBytes: JSON.stringify(preparedRequest).length * 2, timeoutMs: 60_000 },
),
admissionFamilies,
);
const result = unwrapSessionTranscriptWorkerReply<SessionHistoryWorkerInput["kind"]>(reply);
if (
@ -673,17 +600,22 @@ export async function withSessionHistoryWorkerReadCandidates<T>(
env: captureSessionTranscriptStorageEnvironment(request.env),
candidates: capturedCandidates,
};
const reply = await lane.pool.run(
() => {
assertCurrent();
dispatched = true;
lane.nativeSequence++;
return { kind: "session-target-inventory", request: preparedRequest };
},
{
inputBytes: measureSessionStoreTargetInventoryInputBytes(preparedRequest),
timeoutMs: 60_000,
},
const reply = await runWithSqliteDatabaseAdmissionTurn(
admissionPaths,
() =>
lane.pool.run(
() => {
assertCurrent();
dispatched = true;
lane.nativeSequence++;
return { kind: "session-target-inventory", request: preparedRequest };
},
{
inputBytes: measureSessionStoreTargetInventoryInputBytes(preparedRequest),
timeoutMs: 60_000,
},
),
admissionFamilies,
);
const result =
unwrapSessionTranscriptWorkerReply<SessionHistoryWorkerInput["kind"]>(reply);

View file

@ -1,14 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { expectDefined } from "@openclaw/normalization-core";
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import {
UsageCostWorkerReplyError,
type UsageCostWorkerInput,
type UsageCostWorkerResult,
type SessionCostUsageWorkerOptions,
type SessionCostUsageWorkerScope,
} from "../../infra/session-cost-usage-worker.types.js";
import { withSqliteWorkerCleanupFailure } from "../../infra/sqlite-worker-broker-reply.js";
import { runWithSqliteDatabaseAdmissionTurn } from "../../infra/sqlite-database-admission-turn.js";
import { assertExistingDatabaseIdentity } from "../../infra/sqlite-worker-identity.js";
import { WorkerTaskError } from "../../infra/worker-task-pool.js";
import type { WorkerTaskOptions, WorkerTaskResponse } from "../../infra/worker-task-pool.types.js";
@ -45,19 +36,14 @@ import {
import {
acquireHistoryDatabaseResource,
armDatabaseWorkerIdleRetirement,
clearClosedDatabaseCustody,
costReadLane,
costRefreshLane,
historyClearTimeout,
historyLane,
maintenanceLane,
pruneHistoryDatabases,
refreshDatabaseWorkerPressureSubscription,
releaseRetiredDatabaseCustody,
rotateDatabaseWorkers,
settleSessionHistoryWorkerEviction,
type HistoryDatabaseResource,
type SessionCostWorkerLane,
type SessionDatabaseCleanup,
type SessionHistoryDatabaseTarget,
type SessionHistoryWorkerLane,
@ -279,49 +265,51 @@ export function retainSessionHistoryWorkerDatabase(
async (admit, requestLane) => {
try {
const reply = await admit((requestSignal, remaining) =>
requestLane.pool.run(
() => {
assertRequestCurrent();
const input = prepare();
assertRequestCurrent();
sequence = ++requestLane.nativeSequence;
owned.nativeSequences.set(requestLane, sequence);
return {
...input,
database,
validation: validation?.validation,
} satisfies SessionTranscriptWorkerRequest;
},
{
inputBytes: inputBytes + (validation?.inputBytes ?? 0),
timeoutMs: remaining,
signal: requestSignal,
onRequest: onRequest
? (value, context) => {
const effect = (async () => {
context.signal.throwIfAborted();
assertRequestCurrent();
const response = await onRequest(value, context.signal);
context.signal.throwIfAborted();
assertRequestCurrent();
return response ?? { input: null, timeoutMs };
})();
hostEffects.add(effect);
owned.hostEffects.add(effect);
const releaseEffect = () => {
hostEffects.delete(effect);
owned.hostEffects.delete(effect);
};
void effect.then(releaseEffect, releaseEffect);
return effect;
}
: undefined,
onExecutionSettled: ({ retired }) => {
if (retired) {
retirement = rotateDatabaseWorkers(requestLane);
}
runWithSqliteDatabaseAdmissionTurn([database.path], () =>
requestLane.pool.run(
() => {
assertRequestCurrent();
const input = prepare();
assertRequestCurrent();
sequence = ++requestLane.nativeSequence;
owned.nativeSequences.set(requestLane, sequence);
return {
...input,
database,
validation: validation?.validation,
} satisfies SessionTranscriptWorkerRequest;
},
},
{
inputBytes: inputBytes + (validation?.inputBytes ?? 0),
timeoutMs: remaining,
signal: requestSignal,
onRequest: onRequest
? (value, context) => {
const effect = (async () => {
context.signal.throwIfAborted();
assertRequestCurrent();
const response = await onRequest(value, context.signal);
context.signal.throwIfAborted();
assertRequestCurrent();
return response ?? { input: null, timeoutMs };
})();
hostEffects.add(effect);
owned.hostEffects.add(effect);
const releaseEffect = () => {
hostEffects.delete(effect);
owned.hostEffects.delete(effect);
};
void effect.then(releaseEffect, releaseEffect);
return effect;
}
: undefined,
onExecutionSettled: ({ retired }) => {
if (retired) {
retirement = rotateDatabaseWorkers(requestLane);
}
},
},
),
),
);
await retirement;
@ -449,246 +437,6 @@ export function withSessionHistoryWorkerDatabase<T>(
);
}
/** Usage reads retain every physical store while compute and its admitted host effects settle. */
export async function withSessionCostUsageWorkerDatabases<T>(
options: readonly OpenClawAgentDatabaseOptions[],
operation: (owner: SessionCostUsageWorkerScope) => Promise<T>,
): Promise<T> {
if (options.length === 0) {
throw new Error("Usage cost work requires its database owners");
}
const resources = new Set<HistoryDatabaseResource>();
try {
for (const databaseOptions of options) {
const resource = acquireHistoryDatabaseResource(databaseOptions);
if (!resources.has(resource)) {
resources.add(resource);
resource.pending++;
}
}
} catch (error) {
for (const resource of resources) {
resource.pending--;
}
pruneHistoryDatabases();
throw error;
}
const pending = new Set<Promise<UsageCostWorkerResult>>();
const cleanups = new Set<SessionDatabaseCleanup>();
const lanes = new Map<SessionCostWorkerLane, { nativeThrough: number; failedThrough: number }>();
let phase: "open" | "closing" | "closed" = "open";
const assertCurrent = () => {
if (phase === "closed" || [...resources].some((resource) => resource.revoked)) {
throw new WorkerTaskError("Session usage database work was revoked", "unavailable");
}
};
const settle = async () => {
while (pending.size > 0) {
await Promise.allSettled(pending);
}
for (const [lane, custody] of lanes) {
if (custody.nativeThrough > lane.retiredSequence) {
await lane.rotation;
}
if (custody.failedThrough > lane.retiredSequence) {
await rotateDatabaseWorkers(lane);
}
}
};
const retainCleanup = (close: () => Promise<void>): (() => void) => {
if (phase === "closed") {
throw new WorkerTaskError("Session usage database scope is closed", "unavailable");
}
const runInContext = AsyncLocalStorage.snapshot();
let released = false;
let closing: Promise<void> | undefined;
const release = () => {
released = true;
cleanups.delete(cleanup);
for (const resource of resources) {
resource.cleanups.delete(cleanup);
}
pruneHistoryDatabases();
};
const cleanup: SessionDatabaseCleanup = {
run: () => {
if (released) {
return Promise.resolve();
}
closing ??= (async () => {
await settle();
await runInContext(close);
release();
})().catch((error: unknown) => {
closing = undefined;
throw error;
});
return closing;
},
};
cleanups.add(cleanup);
for (const resource of resources) {
resource.cleanups.add(cleanup);
}
return release;
};
const run = (
input: UsageCostWorkerInput,
runOptions: SessionCostUsageWorkerOptions,
): Promise<UsageCostWorkerResult> => {
assertCurrent();
if (phase !== "open") {
throw new WorkerTaskError("Session usage database scope is closing", "unavailable");
}
const lane = input.operation.kind === "refresh" ? costRefreshLane : costReadLane;
const custody = lanes.get(lane) ?? { nativeThrough: 0, failedThrough: 0 };
lanes.set(lane, custody);
const controller = new AbortController();
const signal = runOptions.signal
? AbortSignal.any([controller.signal, runOptions.signal])
: controller.signal;
const abort = () =>
controller.abort(
new WorkerTaskError("Session usage database work was revoked", "unavailable"),
);
for (const resource of resources) {
resource.aborters.add(abort);
}
historyClearTimeout(lane.idleTimer);
lane.pending++;
refreshDatabaseWorkerPressureSubscription();
const hostEffects = new Set<Promise<WorkerTaskResponse>>();
const onRequest = runOptions.onRequest;
let sequence = 0;
let executionSettled = false;
const task = (async (): Promise<UsageCostWorkerResult> => {
try {
const reply = await lane.pool.run(
() => {
assertCurrent();
signal.throwIfAborted();
runOptions.beforeDispatch?.();
sequence = ++lane.nativeSequence;
custody.nativeThrough = sequence;
for (const resource of resources) {
resource.nativeSequences.set(lane, sequence);
}
return { ...input, databases: [...resources].map((resource) => resource.database) };
},
{
...runOptions,
signal,
onExecutionSettled: ({ retired }) => {
executionSettled = true;
if (retired && sequence > 0) {
releaseRetiredDatabaseCustody(lane, sequence);
}
},
onRequest: onRequest
? (value, context) => {
const effect = createDeferredCore<WorkerTaskResponse>();
hostEffects.add(effect.promise);
for (const resource of resources) {
resource.hostEffects.add(effect.promise);
}
const releaseEffect = () => {
hostEffects.delete(effect.promise);
for (const resource of resources) {
resource.hostEffects.delete(effect.promise);
}
};
void effect.promise.then(releaseEffect, releaseEffect);
try {
assertCurrent();
context.signal.throwIfAborted();
effect.resolve(onRequest(value, context));
} catch (error) {
effect.reject(error);
}
return effect.promise;
}
: undefined,
},
);
if (!reply.ok) {
throw new UsageCostWorkerReplyError(reply.error);
}
clearClosedDatabaseCustody(lane, sequence, reply.closedDatabases);
signal.throwIfAborted();
assertCurrent();
return reply.value;
} catch (error) {
if (sequence > 0 && !executionSettled) {
custody.failedThrough = Math.max(custody.failedThrough, sequence);
try {
await rotateDatabaseWorkers(lane);
} catch (cleanupError) {
throw withSqliteWorkerCleanupFailure(
toErrorObject(error, "Usage cost worker failed"),
cleanupError,
);
}
}
throw error;
} finally {
// Native worker exit does not settle an already admitted host write.
await Promise.allSettled(hostEffects);
for (const resource of resources) {
resource.aborters.delete(abort);
}
lane.pending--;
pruneHistoryDatabases();
armDatabaseWorkerIdleRetirement(lane);
}
})();
pending.add(task);
void task.then(
() => pending.delete(task),
() => pending.delete(task),
);
return task;
};
let result: { ok: true; value: T } | { ok: false; error: unknown };
try {
assertCurrent();
const value = await operation({ assertCurrent, run, retainCleanup });
assertCurrent();
result = { ok: true, value };
} catch (error) {
result = { ok: false, error };
}
phase = "closing";
try {
await settle();
for (const cleanup of cleanups) {
await cleanup.run();
}
if (result.ok) {
assertCurrent();
}
} catch (cleanupError) {
throw result.ok
? cleanupError
: withSqliteWorkerCleanupFailure(
toErrorObject(result.error, "Usage cost operation failed"),
cleanupError,
);
} finally {
phase = "closed";
for (const resource of resources) {
resource.pending--;
}
pruneHistoryDatabases();
for (const lane of lanes.keys()) {
armDatabaseWorkerIdleRetirement(lane);
}
}
if (!result.ok) {
throw result.error;
}
return result.value;
}
/** Process-held sources exchange bounded pages without reopening their memory database. */
export async function runProcessHeldHistoryTask(
request: import("./session-history-types.js").ChatHistoryDisplayRequest,

View file

@ -66,6 +66,7 @@ it("borrows canonical writer proof without a native reader and still fences fore
const validation = {
agentId: retained.agentId,
identity: retained.identity,
birthtime: retained.birthtime,
receiptId: retained.receiptId,
valid: retained.valid,
canonicalReady: retained.canonicalReady,
@ -210,7 +211,7 @@ it("transfers bounded history JSON without retaining the worker buffer", async (
});
it.for([
{ change: "newer schema", sql: "PRAGMA user_version = 999", error: /newer schema version/ },
{ change: "newer schema", sql: "PRAGMA user_version = 999", error: undefined },
{
change: "missing table",
sql: "DROP TABLE session_nodes",
@ -222,7 +223,7 @@ it.for([
error: undefined,
},
])(
"revalidates a warm worker listing after a foreign $change",
"keeps admitted format and observes row availability after a foreign $change",
async ({ sql, error }, { signal }) => {
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
const options = { agentId: "main", env: state.env };
@ -280,7 +281,7 @@ it.for([
} else {
expect(result).toMatchObject(expected);
}
// Refresh the retained admission, rather than hiding the bug with a cold reader.
// The same retained reader handles the changed file.
expect(countOpens()).toBe(1);
if (!error) {
const receipt = getOpenClawAgentDatabaseValidationForTransfer(database);

View file

@ -1,3 +1,4 @@
import fs from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { Worker } from "node:worker_threads";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -45,6 +46,8 @@ describe("Gateway shared-state integrity readiness", () => {
expect(await read()).toEqual([]);
await closeOpenClawStateDatabaseAsync();
fs.renameSync(pathname, `${pathname}.template`);
fs.copyFileSync(`${pathname}.template`, pathname, fs.constants.COPYFILE_EXCL);
const database = new DatabaseSync(pathname);
try {
database.exec(`INSERT INTO audit_events

View file

@ -1,10 +1,86 @@
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { expect, it, type Mock, type vi } from "vitest";
import type {
AgentDeletionJournalCleanupPath,
AgentDeletionJournalEntry,
} from "../../state/agent-deletion-journal.js";
type MockIdentity = {
name?: string;
theme?: string;
emoji?: string;
avatar?: string;
};
export type MockAgentEntry = {
id: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: MockIdentity;
};
export type MockConfig = {
agents?: {
entries?: Record<string, Omit<MockAgentEntry, "id">>;
};
};
export function getAgentList(cfg: unknown): MockAgentEntry[] {
return Object.entries((cfg as MockConfig | undefined)?.agents?.entries ?? {}).map(([id, entry]) =>
Object.assign({}, entry, { id }),
);
}
export function mergeAgentConfig(cfg: unknown, opts: unknown): MockConfig {
const config = (cfg as MockConfig | undefined) ?? {};
const params = (opts as {
agentId?: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: string | null;
identity?: MockIdentity;
}) ?? { agentId: "" };
const list = getAgentList(config);
const agentId = params.agentId ?? "";
const index = list.findIndex((entry) => entry.id === agentId);
const base = index >= 0 ? expectDefined(list[index], "existing agent entry") : { id: agentId };
const nextEntry: MockAgentEntry = {
...base,
...(params.name ? { name: params.name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
...(params.identity ? { identity: { ...base.identity, ...params.identity } } : {}),
};
if (params.model === null) {
delete nextEntry.model;
}
if (index >= 0) {
list[index] = nextEntry;
} else {
list.push(nextEntry);
}
return {
...config,
agents: {
...config.agents,
entries: Object.fromEntries(list.map(({ id, ...entry }) => [id, entry])),
},
};
}
export function resolveMockWorkspaceDir(cfg: unknown, agentId?: string): string {
const resolvedAgentId = agentId ?? "";
return (
getAgentList(cfg).find((entry) => entry.id === resolvedAgentId)?.workspace ??
`/workspace/${resolvedAgentId}`
);
}
export function cleanupPath(
pathname: string,
overrides: Partial<AgentDeletionJournalCleanupPath> = {},

View file

@ -19,9 +19,14 @@ import {
expectRespondErrorContaining,
expectRespondOk,
firstRespondResult,
getAgentList,
makeFileStat,
mergeAgentConfig,
mockCallArg,
registerAgentCreationCommitTests,
resolveMockWorkspaceDir,
type MockAgentEntry,
type MockConfig,
} from "./agents-mutate.test-support.js";
const mocks = vi.hoisted(() => ({
sharedAuthStoreOwnership: { location: "legacy-main" } as {
@ -560,81 +565,6 @@ async function call(method: keyof typeof agentsHandlers, params: Record<string,
return respond;
}
type MockIdentity = {
name?: string;
theme?: string;
emoji?: string;
avatar?: string;
};
type MockAgentEntry = {
id: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: MockIdentity;
};
type MockConfig = {
agents?: {
entries?: Record<string, Omit<MockAgentEntry, "id">>;
};
};
function getAgentList(cfg: unknown): MockAgentEntry[] {
return Object.entries((cfg as MockConfig | undefined)?.agents?.entries ?? {}).map(([id, entry]) =>
Object.assign({}, entry, { id }),
);
}
function mergeAgentConfig(cfg: unknown, opts: unknown): MockConfig {
const config = (cfg as MockConfig | undefined) ?? {};
const params = (opts as {
agentId?: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: string | null;
identity?: MockIdentity;
}) ?? { agentId: "" };
const list = getAgentList(config);
const agentId = params.agentId ?? "";
const index = list.findIndex((entry) => entry.id === agentId);
const base = index >= 0 ? expectDefined(list[index], "existing agent entry") : { id: agentId };
const nextEntry: MockAgentEntry = {
...base,
...(params.name ? { name: params.name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.model ? { model: params.model } : {}),
...(params.identity ? { identity: { ...base.identity, ...params.identity } } : {}),
};
if (params.model === null) {
delete nextEntry.model;
}
if (index >= 0) {
list[index] = nextEntry;
} else {
list.push(nextEntry);
}
return {
...config,
agents: {
...config.agents,
entries: Object.fromEntries(list.map(({ id, ...entry }) => [id, entry])),
},
};
}
function resolveMockWorkspaceDir(cfg: unknown, agentId?: string): string {
const resolvedAgentId = agentId ?? "";
return (
getAgentList(cfg).find((entry) => entry.id === resolvedAgentId)?.workspace ??
`/workspace/${resolvedAgentId}`
);
}
async function listAgentFileNames(agentId = "main") {
const respond = await call("agents.files.list", { agentId });
@ -1578,7 +1508,14 @@ describe("agents.delete", () => {
"test-agent",
);
expect(mocks.assertNoOpenClawAgentDatabaseLeases).toHaveBeenCalledWith("test-agent", {});
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith("test-agent", []);
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith(
"test-agent",
[],
expect.objectContaining({
assertCurrentFinal: mocks.assertAgentDeletionCurrentFinal,
assertCurrentAsync: mocks.assertAgentDeletionCurrentAsync,
}),
);
expectNotTrashed("/journal/agent");
expectNotTrashed("/journal");
expectTrashedWithinParent("/deleted/sessions");
@ -1659,9 +1596,14 @@ describe("agents.delete", () => {
);
expectNotTrashed("/linked/shared/agent.sqlite");
expectNotTrashed("/linked/shared/agent.sqlite-wal");
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith("test-agent", [
"/agents/test-agent/openclaw-agent.sqlite",
]);
expect(mocks.closeDeletedAgentDatabases).toHaveBeenCalledWith(
"test-agent",
["/agents/test-agent/openclaw-agent.sqlite"],
expect.objectContaining({
assertCurrentFinal: mocks.assertAgentDeletionCurrentFinal,
assertCurrentAsync: mocks.assertAgentDeletionCurrentAsync,
}),
);
expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledWith({ unregisterDatabases: true });
expect(mocks.beginAgentDeletionFinish).toHaveBeenCalledOnce();
});

View file

@ -613,9 +613,8 @@ export const agentsHandlers: GatewayRequestHandlers = {
await deletion.assertCurrentAsync();
const { closeDeletedAgentDatabases } =
await import("../../state/openclaw-agent-db-readers.js");
await deletion.assertCurrentAsync();
await closeDeletedAgentDatabases(agentId, databasePlan?.readerPaths ?? []);
await deletion.assertCurrentAsync();
const readerPaths = databasePlan?.readerPaths ?? [];
await closeDeletedAgentDatabases(agentId, readerPaths, deletion);
const removed: AgentDeleteRemovedPath[] = [];
const failed: AgentDeleteFailedPath[] = [];

View file

@ -16,7 +16,7 @@ import {
import { SessionTranscriptProjectionUnavailableError } from "../config/sessions/session-transcript-projection-error.js";
import { resolveSessionTranscriptReadFence } from "../config/sessions/session-transcript-read-fence.js";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import { hasSqlitePostCommitScope } from "../infra/sqlite-post-commit.js";
import { hasUncommittedSqliteWrites } from "../infra/sqlite-schema-facts.js";
import { isImageMediaFact, readPersistedMediaFacts } from "../media/media-facts.js";
import type { TranscriptReadWindow } from "../sessions/transcript-read-window.js";
import {
@ -345,7 +345,7 @@ export function readArtifactSummariesFromProjection(
query: Extract<SessionArtifactReadQuery, { kind: "list" }>,
): ArtifactRecord[] {
const cacheable =
!hasSqlitePostCommitScope(projection.database.db) &&
!hasUncommittedSqliteWrites(projection.database.db) &&
!resolveSessionTranscriptReadFence(projection.resolved);
let cache = summaryLists.get(projection.database.db);
if (!cache && cacheable) {

View file

@ -13,7 +13,6 @@ import {
addSessionMember,
removeSessionMember,
} from "../config/sessions/session-sharing-store.native.js";
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../state/openclaw-agent-db-contract.js";
import { registerOpenClawAgentDatabase } from "../state/openclaw-agent-db-registry.js";
import { openOpenClawAgentDatabase } from "../state/openclaw-agent-db.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
@ -43,14 +42,9 @@ function inWriterTransaction(db: DatabaseSync, check: () => void) {
}
describe("committed session mutation authorization", () => {
it.each([
{ mode: "resident", revocation: "membership" },
{ mode: "fixed", revocation: "membership" },
{ mode: "fixed", revocation: "schema-owner" },
{ mode: "fixed", revocation: "schema-version" },
] as const)(
"keeps $mode worker grants current after $revocation changes without shared-state reads",
async ({ mode, revocation }) => {
it.each(["resident", "fixed"] as const)(
"keeps %s worker grants current after membership changes without shared-state reads",
async (mode) => {
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
const cfg = rolePolicyConfig();
if (mode === "fixed") {
@ -159,17 +153,9 @@ describe("committed session mutation authorization", () => {
} else {
const foreign = new DatabaseSync(source.path);
try {
if (revocation === "schema-owner") {
foreign
.prepare("UPDATE schema_meta SET agent_id = ? WHERE meta_key = 'primary'")
.run("another-owner");
} else if (revocation === "schema-version") {
foreign.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`);
} else {
foreign
.prepare("DELETE FROM session_members WHERE session_key = ? AND identity_id = ?")
.run(sessionKey, identityId);
}
foreign
.prepare("DELETE FROM session_members WHERE session_key = ? AND identity_id = ?")
.run(sessionKey, identityId);
} finally {
foreign.close();
}

View file

@ -125,26 +125,32 @@ it.each([false, true])(
async (reclaimed) => {
const root = dirs.make("backup-scratch-create-open-");
const open = nodeSqlite.openNodeSqliteDatabase;
const createDirectory = privateDirectory.createPrivateSqliteTempDirectory;
let failedDirectory: string | undefined;
let nativeFailure: unknown;
vi.spyOn(nodeSqlite, "openNodeSqliteDatabase").mockImplementation((location, options) => {
if (failedDirectory) {
return open(location, options);
}
failedDirectory = path.dirname(location);
if (reclaimed) {
fsSync.rmSync(failedDirectory, { recursive: true });
}
try {
return open(
reclaimed ? location : path.join(failedDirectory, "missing", "owner.sqlite"),
options,
vi.spyOn(privateDirectory, "createPrivateSqliteTempDirectory").mockImplementationOnce(
async (...args) => {
const directory = await createDirectory(...args);
vi.spyOn(nodeSqlite, "openNodeSqliteDatabase").mockImplementationOnce(
(location, options) => {
failedDirectory = directory;
if (reclaimed) {
fsSync.rmSync(directory, { recursive: true });
}
try {
return open(
reclaimed ? location : path.join(directory, "missing", "owner.sqlite"),
options,
);
} catch (error) {
nativeFailure = error;
throw error;
}
},
);
} catch (error) {
nativeFailure = error;
throw error;
}
});
return directory;
},
);
let scratch: Awaited<ReturnType<typeof createBackupScratchDirectory>> | undefined;
try {
const outcome = await createBackupScratchDirectory(root).then(

View file

@ -48,8 +48,8 @@ export async function createBackupScratchDirectory(root: string): Promise<Backup
if (
isSqliteLockError(error) ||
error instanceof SqliteStagingRetiredError ||
isMissingPathError(error) ||
((isSqliteNativeOpenFailure(error) ||
((isMissingPathError(error) ||
isSqliteNativeOpenFailure(error) ||
(error instanceof FsSafeError &&
error.code === "path-mismatch" &&
isMissingPathError(error.cause))) &&

View file

@ -5,6 +5,7 @@ import { getEnvironmentData, isMainThread, setEnvironmentData } from "node:worke
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { probeSqliteNativeClose } from "./bun-sqlite-close-probe.js";
import { parseDiagnosticEnvFlags } from "./diagnostic-flags-env.js";
import { SQLITE_DATABASE_ADMISSIONS_KEY } from "./sqlite-database-admission-key.js";
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
export type SqliteLibrarySelection =
@ -329,6 +330,7 @@ export function captureSqliteWorkerEnvironmentData(): ReadonlyArray<
SQLITE_EXPECTED_SCHEMA_CONTRACTS_KEY,
getEnvironmentData(SQLITE_EXPECTED_SCHEMA_CONTRACTS_KEY),
],
[SQLITE_DATABASE_ADMISSIONS_KEY, getEnvironmentData(SQLITE_DATABASE_ADMISSIONS_KEY)],
];
}

View file

@ -8,6 +8,7 @@ import "../test-utils/prepare-compiled-subprocesses.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { createDeferredCore } from "../shared/deferred.js";
import type { RuntimeWorkerGeneration } from "./runtime-worker-generation.js";
import { SQLITE_DATABASE_ADMISSIONS_KEY } from "./sqlite-database-admission-key.js";
import type { SqliteWorkerBroker } from "./sqlite-worker-broker.js";
import type { SqliteWorkerStore } from "./sqlite-worker-contract.js";
import type { FixtureOperations } from "./sqlite-worker-store.test-support.js";
@ -424,15 +425,23 @@ describe("Bun SQLite process selection and worker inheritance", () => {
{ bun: false, platform: "darwin" },
{ bun: true, platform: "linux" },
])(
"leaves worker environment facts and native selection untouched on $platform (Bun: $bun)",
"leaves library-selection facts and native selection untouched on $platform (Bun: $bun)",
async ({ bun, platform }) => {
setRuntime(bun, platform);
runtime.getEnvironmentData.mockImplementation(() => {
runtime.getEnvironmentData.mockImplementation((key) => {
if (key === SQLITE_DATABASE_ADMISSIONS_KEY) {
return undefined;
}
throw new Error("Unrelated worker data must not be read");
});
const worker = await enterWorkerHeap();
expect(worker.ensureSqliteLibrarySelected()).toMatchObject({ source: "runtime" });
expect(runtime.getEnvironmentData).not.toHaveBeenCalled();
expect(runtime.getEnvironmentData).not.toHaveBeenCalledWith(
"openclaw.bunSqliteLibrarySelection",
);
expect(runtime.getEnvironmentData).not.toHaveBeenCalledWith(
"openclaw.sqliteRuntimeCapabilities",
);
expect(runtime.setEnvironmentData).not.toHaveBeenCalled();
expect(runtime.dlopen).not.toHaveBeenCalled();
expect(runtime.select).not.toHaveBeenCalled();
@ -563,7 +572,13 @@ describe("Bun SQLite process selection and worker inheritance", () => {
await Promise.allSettled([closing, ...(reopening ? [reopening] : [])]);
}
expect(runtime.select).toHaveBeenCalledExactlyOnceWith("/fixture/sqlite.dylib");
expect(runtime.setEnvironmentData).toHaveBeenCalledTimes(2);
expect(
runtime.setEnvironmentData.mock.calls.filter(([key]) =>
["openclaw.bunSqliteLibrarySelection", "openclaw.sqliteRuntimeCapabilities"].includes(
String(key),
),
),
).toHaveLength(2);
},
);

View file

@ -78,22 +78,33 @@ function expectedUnsafeSqliteError(version: string, shared: boolean): string {
describe("node SQLite locations", () => {
const dirs = useAutoCleanupTempDirTracker(afterEach);
it("writes existing URI-escaped paths but never creates a missing database", () => {
const pathname = path.join(dirs.make("sqlite-existing-uri-"), "state ?#%.sqlite");
const uri = resolveExistingSqliteFileUri(pathname);
expect(() => openNodeSqliteDatabase(uri)).toThrow();
expect(fs.existsSync(pathname)).toBe(false);
const initial = openNodeSqliteDatabase(pathname);
initial.exec("CREATE TABLE retained(value TEXT) STRICT");
initial.close();
const existing = openNodeSqliteDatabase(uri);
try {
existing.prepare("INSERT INTO retained(value) VALUES(?)").run("written");
expect(existing.prepare("SELECT value FROM retained").all()).toEqual([{ value: "written" }]);
} finally {
existing.close();
}
});
it.each(["absolute", "relative"])(
"writes existing %s URI-escaped paths without creating missing databases",
(kind) => {
const pathname = path.join(
dirs.make(".sqlite-existing-uri-", kind === "relative" ? process.cwd() : undefined),
"state ?#%.sqlite",
);
const uri =
kind === "relative"
? `file:${path.relative(process.cwd(), pathname).split(path.sep).map(encodeURIComponent).join("/")}?mode=rw`
: resolveExistingSqliteFileUri(pathname);
expect(() => openNodeSqliteDatabase(uri)).toThrow();
expect(fs.existsSync(pathname)).toBe(false);
const initial = openNodeSqliteDatabase(uri.replace(/\?mode=rw$/u, ""));
initial.exec("CREATE TABLE retained(value TEXT) STRICT");
initial.close();
const existing = openNodeSqliteDatabase(uri);
try {
existing.prepare("INSERT INTO retained(value) VALUES(?)").run("written");
expect(existing.prepare("SELECT value FROM retained").all()).toEqual([
{ value: "written" },
]);
} finally {
existing.close();
}
},
);
it("preserves Windows long paths in non-creating writable URIs", () => {
const pathname = String.raw`C:\deep state\openclaw.sqlite`;
expect(resolveExistingSqliteFileUri(pathname, "win32")).toBe(
@ -117,8 +128,21 @@ describe("node SQLite locations", () => {
expect(resolveNodeSqliteLocation("relative/openclaw.sqlite")).toBe("relative/openclaw.sqlite");
});
it("opens special locations through the shared connection boundary", () => {
const database = openNodeSqliteDatabase(":memory:", { timeout: 5000 });
it.each([
":memory:",
"file::memory:",
"file::memory:?cache=shared",
"file:checkonce-memory?mode=memory&cache=shared",
])("opens in-memory location %s without creating a disk file", (location) => {
const open = fs.openSync;
vi.spyOn(fs, "openSync").mockImplementation((filename, flags, mode) => {
// Keep the regression from leaving a literal :memory: file in the checkout.
if (filename === ":memory:") {
throw new Error("In-memory SQLite attempted to create a disk file");
}
return open(filename, flags, mode);
});
const database = openNodeSqliteDatabase(location, { timeout: 5000 });
try {
expect(database.prepare("PRAGMA busy_timeout").get()).toEqual({ timeout: 5000 });
const identity = " a\0🦞 ";
@ -214,6 +238,10 @@ describe("node SQLite safety", () => {
expect(captureSqliteNativeRuntimeAdmission()).toMatchObject({
version: "3.51.3",
extensionLoadingSupported: omitted === 0,
iteratorBehavior: {
nextAfterDoneIsTerminal: expect.any(Boolean),
returnAfterDoneIsInert: expect.any(Boolean),
},
});
expect(prepare).toHaveBeenCalledOnce();
},
@ -297,6 +325,7 @@ describe("node SQLite safety", () => {
const native = mockBunSqliteNativeBoundary({ isBun: false });
const parent = await loadNodeSqliteWithVersion("3.44.6", 1);
parent.requireNodeSqlite();
const iteratorBehavior = parent.captureSqliteNativeRuntimeAdmission()?.iteratorBehavior;
parent.prepare.mockRestore();
const receiptKey = "openclaw.sqliteNativeRuntimeAdmission";
const receipt = native.environment.get(receiptKey);
@ -343,6 +372,9 @@ describe("node SQLite safety", () => {
expect(worker.supportsNodeSqliteWalCheckpointNoop()).toBe(changed !== "unchanged");
if (changed === "unchanged") {
expect(worker.prepare).not.toHaveBeenCalled();
expect(worker.captureSqliteNativeRuntimeAdmission()?.iteratorBehavior).toEqual(
iteratorBehavior,
);
} else {
expect(worker.prepare).toHaveBeenCalled();
}

View file

@ -13,13 +13,23 @@ import {
import { formatErrorMessage } from "./errors.js";
import { registerNodeSqliteDisposeCallback } from "./kysely-sync-cache-state.js";
import { compareValidSemver } from "./semver.js";
import {
bindSqliteDatabaseAdmission,
prepareSqliteDatabaseAdmission,
} from "./sqlite-database-admission.js";
import {
probeSqliteIteratorBehavior,
type SqliteIteratorBehavior,
} from "./sqlite-native-observer.js";
import { registerSqliteReaderConnection } from "./sqlite-reader-lifecycle.js";
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
import { trackSqliteSchema } from "./sqlite-schema-facts.js";
import { installProcessWarningFilter } from "./warning-filter.js";
const require = createRequire(import.meta.url);
let validatedSqliteModule: typeof import("node:sqlite") | undefined;
let validatedSqliteModule:
| { sqlite: typeof import("node:sqlite"); iteratorBehavior: SqliteIteratorBehavior }
| undefined;
let extensionLoadingSupported = false;
let jsonbSupported = false;
let walCheckpointNoopSupported = false;
@ -106,6 +116,7 @@ type SqliteNativeRuntimeAdmission = {
runtime: ReturnType<typeof sqliteNativeRuntimeIdentity>;
version: string;
extensionLoadingSupported: boolean;
iteratorBehavior: SqliteIteratorBehavior;
};
function parseSqliteNativeRuntimeAdmission(
@ -115,7 +126,10 @@ function parseSqliteNativeRuntimeAdmission(
!isRecord(value) ||
value.format !== 1 ||
typeof value.version !== "string" ||
typeof value.extensionLoadingSupported !== "boolean"
typeof value.extensionLoadingSupported !== "boolean" ||
!isRecord(value.iteratorBehavior) ||
typeof value.iteratorBehavior.nextAfterDoneIsTerminal !== "boolean" ||
typeof value.iteratorBehavior.returnAfterDoneIsInert !== "boolean"
) {
return undefined;
}
@ -128,6 +142,10 @@ function parseSqliteNativeRuntimeAdmission(
runtime,
version: value.version,
extensionLoadingSupported: value.extensionLoadingSupported,
iteratorBehavior: {
nextAfterDoneIsTerminal: value.iteratorBehavior.nextAfterDoneIsTerminal,
returnAfterDoneIsInert: value.iteratorBehavior.returnAfterDoneIsInert,
},
};
}
@ -154,9 +172,9 @@ export function installSqliteNativeRuntimeAdmission(value: unknown): void {
}
}
function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): void {
if (validatedSqliteModule === sqlite) {
return;
function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): SqliteIteratorBehavior {
if (validatedSqliteModule?.sqlite === sqlite) {
return validatedSqliteModule.iteratorBehavior;
}
const inherited = isMainThread
? undefined
@ -167,36 +185,39 @@ function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): void {
jsonbSupported = (compareValidSemver(inherited.version, "3.45.0") ?? -1) >= 0;
walCheckpointNoopSupported = (compareValidSemver(inherited.version, "3.53.0") ?? -1) >= 0;
extensionLoadingSupported = inherited.extensionLoadingSupported;
validatedSqliteModule = sqlite;
return;
validatedSqliteModule = { sqlite, iteratorBehavior: inherited.iteratorBehavior };
return inherited.iteratorBehavior;
}
// Shared-SQLite Node builds can load a different library than process.versions
// reports, so query the loaded library before callers open real state databases.
const database = new sqlite.DatabaseSync(":memory:");
let version: string;
let extensions: boolean;
let iteratorBehavior: SqliteIteratorBehavior;
try {
const row = database
.prepare(
"SELECT sqlite_version() AS version, sqlite_compileoption_used('OMIT_LOAD_EXTENSION') AS omitted",
)
.get() as { version?: unknown; omitted?: unknown } | undefined;
const statement = database.prepare(
"SELECT sqlite_version() AS version, sqlite_compileoption_used('OMIT_LOAD_EXTENSION') AS omitted",
);
const row = statement.get() as { version?: unknown; omitted?: unknown } | undefined;
version = typeof row?.version === "string" ? row.version : "unknown";
assertSqliteWalResetSafeVersion(version, process.versions.node);
extensions = row?.omitted === 0;
iteratorBehavior = probeSqliteIteratorBehavior(statement);
} finally {
database.close();
}
jsonbSupported = (compareValidSemver(version, "3.45.0") ?? -1) >= 0;
walCheckpointNoopSupported = (compareValidSemver(version, "3.53.0") ?? -1) >= 0;
extensionLoadingSupported = extensions;
validatedSqliteModule = sqlite;
validatedSqliteModule = { sqlite, iteratorBehavior };
setEnvironmentData(SQLITE_NATIVE_RUNTIME_ADMISSION_KEY, {
format: 1,
runtime: sqliteNativeRuntimeIdentity(),
version,
extensionLoadingSupported: extensions,
iteratorBehavior,
});
return iteratorBehavior;
}
// node:sqlite is optional across Node versions, so callers get a clear runtime
@ -244,9 +265,37 @@ export function openNodeSqliteDatabase(
// Callers may pass file: URIs or already-namespaced paths from specialized
// resolvers; location normalization must remain idempotent for those forms.
const resolvedLocation = resolveNodeSqliteLocation(location);
const identity =
options?.open === false
? undefined
: prepareSqliteDatabaseAdmission(resolvedLocation, { create: options?.readOnly !== true });
const database = new sqlite.DatabaseSync(resolvedLocation, options ?? {});
if (database.isOpen) {
try {
bindSqliteDatabaseAdmission(database, identity);
} catch (error) {
database.close();
throw error;
}
}
database.open = () => {
const reopenedIdentity = prepareSqliteDatabaseAdmission(resolvedLocation, {
create: options?.readOnly !== true,
});
sqlite.DatabaseSync.prototype.open.call(database);
try {
bindSqliteDatabaseAdmission(database, reopenedIdentity);
} catch (error) {
database.close();
throw error;
}
};
// Schema tracking must precede the statement-cache authorizer wrapper.
trackSqliteSchema(database, sqlite);
trackSqliteSchema(database, {
DatabaseSync: sqlite.DatabaseSync,
StatementSync: sqlite.StatementSync,
iteratorBehavior: assertSafeSqliteRuntime(sqlite),
});
if (!getSqliteRuntimeCapabilities().explicitSqliteCloseReleasesNativeResources) {
registerNodeSqliteDisposeCallback(database, () => {
bunSqliteNativeCleanupPending = true;

View file

@ -23,7 +23,6 @@ import { restoreSessionColdTranscript } from "../config/sessions/session-cold-st
import { listDurableSqliteTargetPathsForSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js";
import { createMemoryTranscriptProjectionSource } from "../config/sessions/session-transcript-reconcile-memory.js";
import { withSessionCostUsageWorkerDatabases } from "../config/sessions/session-transcript-worker-runtime.js";
import { resolveStateDir } from "../config/state-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@ -60,6 +59,7 @@ import {
} from "./session-cost-usage-pricing-context.js";
import type { UsageCostResolver } from "./session-cost-usage-pricing.js";
import { openUsageCostRefreshFailures } from "./session-cost-usage-refresh-health.js";
import { withSessionCostUsageWorkerDatabases } from "./session-cost-usage-worker-scope.js";
import {
UsageCostWorkerReplyError,
type UsageCostWorkerHostEffects,

View file

@ -0,0 +1,274 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import {
acquireHistoryDatabaseResource,
armDatabaseWorkerIdleRetirement,
clearClosedDatabaseCustody,
costReadLane,
costRefreshLane,
historyClearTimeout,
pruneHistoryDatabases,
refreshDatabaseWorkerPressureSubscription,
releaseRetiredDatabaseCustody,
rotateDatabaseWorkers,
type HistoryDatabaseResource,
type SessionCostWorkerLane,
type SessionDatabaseCleanup,
} from "../config/sessions/session-transcript-worker-resources.js";
import { createDeferredCore } from "../shared/deferred.js";
import type { OpenClawAgentDatabaseOptions } from "../state/openclaw-agent-db-contract.js";
import {
UsageCostWorkerReplyError,
type UsageCostWorkerInput,
type UsageCostWorkerResult,
type SessionCostUsageWorkerOptions,
type SessionCostUsageWorkerScope,
} from "./session-cost-usage-worker.types.js";
import { runWithSqliteDatabaseAdmissionTurn } from "./sqlite-database-admission-turn.js";
import { withSqliteWorkerCleanupFailure } from "./sqlite-worker-broker-reply.js";
import { WorkerTaskError } from "./worker-task-pool.js";
import type { WorkerTaskResponse } from "./worker-task-pool.types.js";
/** Usage reads retain every physical store while compute and its admitted host effects settle. */
export async function withSessionCostUsageWorkerDatabases<T>(
options: readonly OpenClawAgentDatabaseOptions[],
operation: (owner: SessionCostUsageWorkerScope) => Promise<T>,
): Promise<T> {
if (options.length === 0) {
throw new Error("Usage cost work requires its database owners");
}
const resources = new Set<HistoryDatabaseResource>();
try {
for (const databaseOptions of options) {
const resource = acquireHistoryDatabaseResource(databaseOptions);
if (!resources.has(resource)) {
resources.add(resource);
resource.pending++;
}
}
} catch (error) {
for (const resource of resources) {
resource.pending--;
}
pruneHistoryDatabases();
throw error;
}
const pending = new Set<Promise<UsageCostWorkerResult>>();
const cleanups = new Set<SessionDatabaseCleanup>();
const lanes = new Map<SessionCostWorkerLane, { nativeThrough: number; failedThrough: number }>();
let phase: "open" | "closing" | "closed" = "open";
const assertCurrent = () => {
if (phase === "closed" || [...resources].some((resource) => resource.revoked)) {
throw new WorkerTaskError("Session usage database work was revoked", "unavailable");
}
};
const settle = async () => {
while (pending.size > 0) {
await Promise.allSettled(pending);
}
for (const [lane, custody] of lanes) {
if (custody.nativeThrough > lane.retiredSequence) {
await lane.rotation;
}
if (custody.failedThrough > lane.retiredSequence) {
await rotateDatabaseWorkers(lane);
}
}
};
const retainCleanup = (close: () => Promise<void>): (() => void) => {
if (phase === "closed") {
throw new WorkerTaskError("Session usage database scope is closed", "unavailable");
}
const runInContext = AsyncLocalStorage.snapshot();
let released = false;
let closing: Promise<void> | undefined;
const release = () => {
released = true;
cleanups.delete(cleanup);
for (const resource of resources) {
resource.cleanups.delete(cleanup);
}
pruneHistoryDatabases();
};
const cleanup: SessionDatabaseCleanup = {
run: () => {
if (released) {
return Promise.resolve();
}
closing ??= (async () => {
await settle();
await runInContext(close);
release();
})().catch((error: unknown) => {
closing = undefined;
throw error;
});
return closing;
},
};
cleanups.add(cleanup);
for (const resource of resources) {
resource.cleanups.add(cleanup);
}
return release;
};
const run = (
input: UsageCostWorkerInput,
runOptions: SessionCostUsageWorkerOptions,
): Promise<UsageCostWorkerResult> => {
assertCurrent();
if (phase !== "open") {
throw new WorkerTaskError("Session usage database scope is closing", "unavailable");
}
const lane = input.operation.kind === "refresh" ? costRefreshLane : costReadLane;
const custody = lanes.get(lane) ?? { nativeThrough: 0, failedThrough: 0 };
lanes.set(lane, custody);
const controller = new AbortController();
const signal = runOptions.signal
? AbortSignal.any([controller.signal, runOptions.signal])
: controller.signal;
const abort = () =>
controller.abort(
new WorkerTaskError("Session usage database work was revoked", "unavailable"),
);
for (const resource of resources) {
resource.aborters.add(abort);
}
historyClearTimeout(lane.idleTimer);
lane.pending++;
refreshDatabaseWorkerPressureSubscription();
const hostEffects = new Set<Promise<WorkerTaskResponse>>();
const onRequest = runOptions.onRequest;
let sequence = 0;
let executionSettled = false;
const task = (async (): Promise<UsageCostWorkerResult> => {
try {
const reply = await runWithSqliteDatabaseAdmissionTurn(
[...resources].map((resource) => resource.database.path),
() =>
lane.pool.run(
() => {
assertCurrent();
signal.throwIfAborted();
runOptions.beforeDispatch?.();
sequence = ++lane.nativeSequence;
custody.nativeThrough = sequence;
for (const resource of resources) {
resource.nativeSequences.set(lane, sequence);
}
return { ...input, databases: [...resources].map((resource) => resource.database) };
},
{
...runOptions,
signal,
onExecutionSettled: ({ retired }) => {
executionSettled = true;
if (retired && sequence > 0) {
releaseRetiredDatabaseCustody(lane, sequence);
}
},
onRequest: onRequest
? (value, context) => {
const effect = createDeferredCore<WorkerTaskResponse>();
hostEffects.add(effect.promise);
for (const resource of resources) {
resource.hostEffects.add(effect.promise);
}
const releaseEffect = () => {
hostEffects.delete(effect.promise);
for (const resource of resources) {
resource.hostEffects.delete(effect.promise);
}
};
void effect.promise.then(releaseEffect, releaseEffect);
try {
assertCurrent();
context.signal.throwIfAborted();
effect.resolve(onRequest(value, context));
} catch (error) {
effect.reject(error);
}
return effect.promise;
}
: undefined,
},
),
);
if (!reply.ok) {
throw new UsageCostWorkerReplyError(reply.error);
}
clearClosedDatabaseCustody(lane, sequence, reply.closedDatabases);
signal.throwIfAborted();
assertCurrent();
return reply.value;
} catch (error) {
if (sequence > 0 && !executionSettled) {
custody.failedThrough = Math.max(custody.failedThrough, sequence);
try {
await rotateDatabaseWorkers(lane);
} catch (cleanupError) {
throw withSqliteWorkerCleanupFailure(
toErrorObject(error, "Usage cost worker failed"),
cleanupError,
);
}
}
throw error;
} finally {
// Native worker exit does not settle an already admitted host write.
await Promise.allSettled(hostEffects);
for (const resource of resources) {
resource.aborters.delete(abort);
}
lane.pending--;
pruneHistoryDatabases();
armDatabaseWorkerIdleRetirement(lane);
}
})();
pending.add(task);
void task.then(
() => pending.delete(task),
() => pending.delete(task),
);
return task;
};
let result: { ok: true; value: T } | { ok: false; error: unknown };
try {
assertCurrent();
const value = await operation({ assertCurrent, run, retainCleanup });
assertCurrent();
result = { ok: true, value };
} catch (error) {
result = { ok: false, error };
}
phase = "closing";
try {
await settle();
for (const cleanup of cleanups) {
await cleanup.run();
}
if (result.ok) {
assertCurrent();
}
} catch (cleanupError) {
throw result.ok
? cleanupError
: withSqliteWorkerCleanupFailure(
toErrorObject(result.error, "Usage cost operation failed"),
cleanupError,
);
} finally {
phase = "closed";
for (const resource of resources) {
resource.pending--;
}
pruneHistoryDatabases();
for (const lane of lanes.keys()) {
armDatabaseWorkerIdleRetirement(lane);
}
}
if (!result.ok) {
throw result.error;
}
return result.value;
}

View file

@ -0,0 +1 @@
export const SQLITE_DATABASE_ADMISSIONS_KEY = "openclaw.sqliteDatabaseAdmissions.v1";

View file

@ -0,0 +1,145 @@
import { threadId } from "node:worker_threads";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readWorkerAncestors } from "./worker-ancestry.js";
export type AdmissionFact = {
value: unknown;
revision: number;
schemaDependent: boolean;
publication: string;
current: SharedArrayBuffer;
};
export type StagedAdmissionFact = Pick<AdmissionFact, "value" | "revision" | "schemaDependent"> & {
ddlRevision: number;
};
type Writer = { cell: SharedArrayBuffer; ancestors: readonly number[] };
export type Admission = {
identity: string;
location: string;
descriptor: number;
descriptorOwner: number;
generationId: string;
generation: SharedArrayBuffer;
writers: Map<number, Writer>;
facts: Map<string, AdmissionFact>;
};
export type SqliteDatabaseAdmissions = Admission[];
function readAdmissionFact(value: unknown): AdmissionFact | undefined {
if (
!isRecord(value) ||
typeof value.revision !== "number" ||
typeof value.schemaDependent !== "boolean" ||
typeof value.publication !== "string" ||
!(value.current instanceof SharedArrayBuffer) ||
value.current.byteLength !== Int32Array.BYTES_PER_ELEMENT
) {
return undefined;
}
return {
value: value.value,
revision: value.revision,
schemaDependent: value.schemaDependent,
publication: value.publication,
current: value.current,
};
}
function readInheritedAdmission(value: unknown): Admission | undefined {
if (
!isRecord(value) ||
typeof value.identity !== "string" ||
typeof value.location !== "string" ||
typeof value.descriptor !== "number" ||
typeof value.descriptorOwner !== "number" ||
typeof value.generationId !== "string" ||
!(value.generation instanceof SharedArrayBuffer) ||
value.generation.byteLength !== 5 * Int32Array.BYTES_PER_ELEMENT ||
!(value.facts instanceof Map)
) {
return undefined;
}
if (!(value.writers instanceof Map)) {
return undefined;
}
const writers = new Map<number, Writer>();
for (const [writer, custody] of value.writers) {
if (
typeof writer !== "number" ||
!Number.isInteger(writer) ||
writer < 0 ||
!isRecord(custody) ||
!(custody.cell instanceof SharedArrayBuffer) ||
custody.cell.byteLength !== 2 * Int32Array.BYTES_PER_ELEMENT
) {
return undefined;
}
const ancestors = readWorkerAncestors(custody.ancestors);
if (!ancestors || ancestors.includes(writer)) {
return undefined;
}
writers.set(writer, { cell: custody.cell, ancestors });
}
const facts = new Map<string, AdmissionFact>();
for (const [key, entry] of value.facts) {
const fact = readAdmissionFact(entry);
if (typeof key !== "string" || !fact) {
return undefined;
}
facts.set(key, fact);
}
return {
identity: value.identity,
location: value.location,
descriptor: value.descriptor,
descriptorOwner: value.descriptorOwner,
generationId: value.generationId,
generation: value.generation,
writers,
facts,
};
}
export function readSqliteDatabaseAdmissions(value: unknown): SqliteDatabaseAdmissions | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const admissions: SqliteDatabaseAdmissions = [];
for (const entry of value) {
const record = readInheritedAdmission(entry);
if (!record) {
return undefined;
}
admissions.push(record);
}
return admissions;
}
export function isSqliteDatabaseAdmissionRetired(record: Admission): boolean {
return Atomics.load(new Int32Array(record.generation), 3) !== 0;
}
export function registerWriterCustody(record: Admission): void {
if (threadId !== 0) {
return;
}
for (const { cell } of record.writers.values()) {
const writer = new Int32Array(cell);
if (Atomics.load(writer, 1) === 0) {
// Only thread 0 allocates registrations, after installing their metadata.
Atomics.add(new Int32Array(record.generation), 4, 1);
Atomics.store(writer, 1, 1);
}
}
}
export function isSqliteDatabaseAdmissionFactCurrent(
record: Admission,
fact: AdmissionFact,
): boolean {
const cell = new Int32Array(record.generation);
return (
!isSqliteDatabaseAdmissionRetired(record) &&
Atomics.load(new Int32Array(fact.current), 0) === 1 &&
fact.revision === Atomics.load(cell, fact.schemaDependent ? 0 : 1)
);
}

View file

@ -0,0 +1,177 @@
import { AsyncLocalStorage } from "node:async_hooks";
import path from "node:path";
import { createDeferredCore } from "../shared/deferred.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { resolveIdentityPathViaExistingAncestorSync } from "./boundary-path.js";
import { isMissingPathError } from "./errno.js";
import {
canShareSqliteDatabaseAdmissions,
hasSqliteDatabaseSchemaAdmissionForPath,
} from "./sqlite-database-admission.js";
import { inspectDatabasePathIdentitySync } from "./sqlite-worker-identity.js";
type AdmissionTurn = {
keys: ReadonlySet<string>;
predecessors: Set<AdmissionTurn>;
completion: Promise<void>;
holding: boolean;
references: number;
releaseReference(): void;
};
type AdmissionTurnScope = { turns: readonly AdmissionTurn[]; active: boolean };
const pending = resolveGlobalSingleton(
Symbol.for("openclaw.sqliteDatabaseAdmissionTurns"),
() => new Map<string, AdmissionTurn>(),
);
const current = resolveGlobalSingleton(
Symbol.for("openclaw.sqliteDatabaseAdmissionTurnContext"),
() => new AsyncLocalStorage<AdmissionTurnScope>(),
);
function reserveAdmissionTurn(locations: string | readonly string[], families: readonly string[]) {
if (!canShareSqliteDatabaseAdmissions()) {
return undefined;
}
const keys = new Set(
families.map((directory) => `family:${resolveIdentityPathViaExistingAncestorSync(directory)}`),
);
for (const location of typeof locations === "string" ? [locations] : locations) {
let identity: ReturnType<typeof inspectDatabasePathIdentitySync>;
try {
// A native task can request a host writer after publishing its completed schema.
if (hasSqliteDatabaseSchemaAdmissionForPath(location)) {
continue;
}
identity = inspectDatabasePathIdentitySync(location);
} catch (error) {
if (!isMissingPathError(error)) {
throw error;
}
}
// Discovery owns unavailable targets; they still share pathname and family ordering.
const canonicalPath =
identity?.canonicalPath ?? resolveIdentityPathViaExistingAncestorSync(location);
keys.add(`path:${canonicalPath}`);
if (identity) {
keys.add(`${identity.key}:${identity.birthtime ?? ""}`);
}
keys.add(`family:${path.dirname(canonicalPath)}`);
}
if (keys.size === 0) {
return undefined;
}
const scope = current.getStore();
const parents = scope?.active ? scope.turns.filter((turn) => turn.holding) : [];
const inherited = new Set(parents);
const ownedKeys = new Set(parents.flatMap((turn) => [...turn.keys]));
const ordered = [...keys].filter((key) => !ownedKeys.has(key)).toSorted();
const predecessors = new Set<AdmissionTurn>();
const waitsForParent = (turn: AdmissionTurn): boolean => {
const search = [turn];
const visited = new Set<AdmissionTurn>();
for (const candidate of search) {
if (inherited.has(candidate)) {
return true;
}
if (!visited.has(candidate)) {
visited.add(candidate);
search.push(...candidate.predecessors);
}
}
return false;
};
const search = ordered.flatMap((key) => pending.get(key) ?? []);
const visited = new Set<AdmissionTurn>();
for (const turn of search) {
if (visited.has(turn)) {
continue;
}
visited.add(turn);
if (inherited.has(turn)) {
continue;
}
if (waitsForParent(turn)) {
// A queued caller cannot stand between an active turn and its nested callback.
// Keep its other blockers, so parallel nested opens still validate each file once.
search.push(...turn.predecessors);
} else {
predecessors.add(turn);
}
}
const completion = createDeferredCore();
const turn: AdmissionTurn = {
keys: new Set(ordered),
predecessors,
completion: completion.promise,
holding: false,
references: 1,
releaseReference() {
if (--turn.references !== 0) {
return;
}
turn.holding = false;
for (const key of ordered) {
if (pending.get(key) === turn) {
pending.delete(key);
}
}
completion.resolve();
for (const parent of parents) {
parent.releaseReference();
}
},
};
for (const parent of parents) {
parent.references += 1;
}
for (const key of ordered) {
pending.set(key, turn);
}
let released = false;
const release = () => {
if (!released) {
released = true;
turn.releaseReference();
}
};
const ready = Promise.all([...predecessors].map((predecessor) => predecessor.completion)).then(
() => {
turn.holding = true;
predecessors.clear();
},
);
return { ready, release, turns: [...parents, turn] };
}
/** Only cold dispatch waits here; native getters never block the host on a worker's publication. */
export function acquireSqliteDatabaseAdmissionTurn(
locations: string | readonly string[],
families: readonly string[] = [],
): Promise<() => void> | undefined {
const turn = reserveAdmissionTurn(locations, families);
return turn?.ready.then(() => turn.release);
}
/** Known database locators are captured by their task owner before dispatch, never inferred from IPC. */
export function runWithSqliteDatabaseAdmissionTurn<T>(
locations: readonly string[],
operation: () => Promise<T>,
families: readonly string[] = [],
): Promise<T> {
const turn = reserveAdmissionTurn(locations, families);
if (!turn) {
return operation();
}
return turn.ready.then(() => {
const scope: AdmissionTurnScope = { turns: turn.turns, active: true };
return current.run(scope, async () => {
try {
return await operation();
} finally {
scope.active = false;
turn.release();
}
});
});
}

Some files were not shown because too many files have changed in this diff Show more