Repository navigation
Added
-
The maintenance loop now reports its own connection health (2026-10-16). Every diagnosis of the 15–16.10 lock storms had to be made from outside, by poking at
/proc: descriptor counts that oscillated between 49 and 57 and could not be read as a trend, and no way at all to ask the process how many connections it believed it had, or how many aiosqlite workers were running with no owner.The numbers that answer that existed all along —
connection_manager.stats()andleaked_connection_workers()— but nothing called them in production; they were built for the test suite._storage_pass()now reads both in the same 15-minute pass the failures come from and logsconnection health: connections=N dbs=... leaked_workers=Nso the next storm has a reading taken moments before it rather than after the fact.
_storage_passis extracted from the periodic loop for a reason worth recording: the first mutation run left every test green after deleting the call from the loop, because the tests called_connection_health()directly. A test that calls the function cannot tell "wired" from "written and forgotten" — the same defect this whole line of work keeps finding in the codebase, and this time in the diagnostic written to find it.test_the_storage_pass_actually_reads_healthdrives the pass instead.Both halves are best-effort: a failing size/VACUUM step must not swallow the health line, because a storm is exactly when the numbers are wanted. A reading that fails reports
-1rather than zeroes — a sentinel, so a broken diagnostic cannot be mistaken for a clean bill of health.Mutation-checked, four mutations, zero survivors: leak count not read (M1), health logged at DEBUG instead of INFO (M2), failures reported as zeroes (M3), and the call removed from the pass (M4, which is the one that failed the first run and prompted the extraction).
Suite 2306 passed (was 2301).
-
The health reading counts descriptors, and the daemon reads it too (2026-10-16). The first version was built from manager bookkeeping alone, and that is not enough.
connection_manager.stats()andleaked_connection_workers()both report what the ASYNC manager owns; they are blind to synchronous handles, andwith sqlite3.connect(...)— which appears 42 times in this repository — is a TRANSACTION context manager that does not close. Measured directly: five such blocks left five open descriptors, alive until garbage collection.So a manager-only reading would print
connections=1 leaked_workers=0while the process held 41 open files on one database. That is precisely what the hermes daemon was doing — 41 descriptors on one file while running only 9 threads, against 1 descriptor for an identical daemon on another base. Nine threads cannot back 41 aiosqlite connections, so the manager was never the whole story.connection_health()now counts two ways and reports both: manager bookkeeping, plus the process's own descriptor table read from/proc/self/fd, withunmanaged_fdsas the difference. The disagreement between them is the finding — that is the number that was missing while this was diagnosed from outside for two days.The autohooks daemon reads it every 600 s from its poll loop, best-effort, so a broken diagnostic cannot stop delivery. The server keeps reading it in the maintenance pass. Both call the same function in
shared/, so the two processes cannot drift apart in what they measure.An unreadable descriptor scan reports
-1, not0— the same sentinel rule as before: a failed reading must not look like a clean bill of health.test_daemon_logs_connection_healthdrivesrun_daemonrather than the helper, because M4 had already shown what a test that calls the helper directly misses.Mutation-checked, five mutations, zero survivors: reading removed from the daemon loop (M1), descriptors not counted (M2), unreadable scan reported as zero (M3),
unmanaged_fdsnot computed (M4), and the call left unguarded so a failure kills the poll loop (M5).Suite 2311 passed (was 2306).
Fixed
-
A cursor could pin a snapshot and wedge every writer (2026-10-18). Every write in the library rode one shared
aiosqliteconnection. A read cursor retained on that connection pinned a WAL read snapshot in shared memory, and after any foreign commit — the autohooks daemon, a cron job, a CLI process — the next write on the same connection failed instantly withSQLITE_BUSY_SNAPSHOT(517), surfacing asdatabase is lockeddespitebusy_timeout=15000, because the busy handler is never consulted for 517. The failure then cascaded: decay failed,prune_dangling_edgesraised, and the periodic pass aborted before the WAL checkpoint, so the WAL grew without bound and the database looked permanently wedged.Nineteen preceding PRs each fixed one symptom of this class — a connection of one's own for the checkpoint, a connection of one's own for the rescore sweep, close-on-drop, a
flockper base. None of them removed the shared connection from the reach of an ordinary caller, so the class stayed open.shared/write_dispatch.pycloses it.WriteDispatchis the sole owner of a private connector built from the manager'sbase_dir; a cursor never leaves a method body — every read opens, consumes and closes inside the call — so no snapshot can outlive the statement that took it. One instance per process (get_write_dispatch), withquery/select_one/scalar/write/insert/write_many/atomic/execute_script.atomicruns a read-then-write callback under the write lock and is re-entrant per task through aContextVar, so a domain writer can join a maintenance transaction instead of deadlocking against it.close_all()tears the dispatcher down with its manager.Every write site moved onto it: core, features, graph, lifecycle, shared, rag, wiki, hooks, the MCP tools and the maintenance scripts — 90 files.
shared/l0.pykeeps its hash-chain transaction (head read, digest, insert, hash update) as a singleatomiccallback, withBEGIN IMMEDIATEretained for cross-process safety. Subsystems that already had a connection of their own — the WAL checkpoint, the importance scheduler, the read-only replica — were left as they are: that pattern was the model, not the target.The
in_transactionguards and the_BUSY_ERRORScatches stay. They still defend against a foreign transaction arriving throughAsyncConnectionManager.execute_script, and they are the regression fence for the class.Verified live, not only in the suite. The shared memory-map byte that marks an open read transaction (
/proc/locks,-shmbyte 123) was held permanently by the server process before the change and is absent after; the periodic pass now reaches the checkpoint, which reclaimed the backlog (memory.db-walfell from a frozen 4.86 MB to 0.1 MB), and three consecutive passes on each of the three deployed bases loggedDecayed/WAL checkpoint/connection health ... leaked_workers=0with zerodatabase is lockedlines. Suite 2361 passed (was 2357). -
Two test-invisible defects surfaced by the migration itself (2026-10-18). Moving transaction ownership exposed commits that were already wrong and had never failed a test, because a commit on a borrowed connection only misbehaves when something later rolls back.
miner_embeddingandwire_new_nodecommitted inside their own@_maintenancewrapper, which already owns the transaction, so they published half of it;lateral_inhibitioncommitted the connection passed to it by_insert_edge. Proven behaviourally — an edge written bywire_new_nodesurvived an outeratomicrollback — then fixed by leaving the commit to the owner. An invariant test now scans the source for any maintenance-wrapped function that commits on its own, rather than trusting the next reader to notice.The same trap sat in three helpers that take a
connand commit it —validate_edges,_dream,wiki_fts_check.repair. Their callers now commit explicitly, and a behavioural test asserts that a rechecked expired edge rolls back with its caller. -
A repointed base directory kept answering from the previous database (2026-10-18).
AsyncConnectionManager.get()cached a handle per database name and never re-examined the path, so a manager whosebase_dirhad been changed returned the old file. Tests that redirect the manager were silently reading and migrating the previous database; the failure only became visible once a fresh dispatch opened the new one and found no tables. The manager now records the path each handle was opened on and reopens on a mismatch. -
A process could outlive its work and hang on exit (2026-10-18). aiosqlite worker threads are not daemons and block in
queue.get(); closing the manager left the dispatcher's connector alive, so short-lived CLIs waited for threads nobody would ever signal.close_all()now closes the dispatchers belonging to that manager. One CLI test that had been timing out for 62 s completes in 3.3 s. -
A connection can be created while another writer holds the file (2026-10-17).
_get_aiosqlite_connran its PRAGMAs unconditionally, and two of them need a write lock:faithful copy, another connection holds a write transaction PRAGMA auto_vacuum=INCREMENTAL waits the full budget, then fails PRAGMA journal_mode=WAL fails INSTANTLY (no timeout can help) the other eight ok, under 0.2 msauto_vacuumwas the worse of the two: it rewrites the header even when the value is already INCREMENTAL, so every single connect asked for a lock it did not need. Measured against a locked base, a new connection failed after 5006 ms —sqlite3.connect's own 5 s default, becausebusy_timeoutwas applied after the statement that blocked. At startup that is theFailed to connectline. Movingbusy_timeoutfirst does not fix it: it only makes the same failure take 15 s.Now the one-time settings read first and write only on a mismatch, run under a 250 ms budget so a blocked attempt costs a fraction of a second, and a lock is treated as "not now" rather than "cannot connect" — the value is permanent once written and retried on the next connect. The operating
busy_timeout(15 s) is restored immediately afterwards.Red-first: the three tests fail on the previous code with
database is lockedafter 15.9 s. Three mutations (no tolerance, no timeout restore, unconditionalauto_vacuum) all turn the suite red. Suite 2344 passed. -
The rescore sweep runs on its own connection (2026-10-17). It used to borrow
connection_manager.get("memory.db")— the one connection every tool handler shares. Two consequences, both measured:handler wrote, not committed -> the pass's commit() -> published to other readers handler wrote, not committed -> the pass's retry path -> could not own a boundary at all a handler's SELECT waited 11.75 s behind the pass's UPDATE on the same connectionNow
_rescore_passopens a short-livedsqlite3connection inasyncio.to_thread, the same shape #127 gave the WAL checkpoint: it inherits nobody's transaction,busy_timeoutapplies to it alone, and commit or rollback reaches nothing but its own work. Per-user commit is back — and is now safe, which is what #129 was reaching for.Measured against a live-shaped base with a real competing writer holding the write transaction for 3 s:
sweep finished in 3.17 s rescored=417 skipped=489 errors=0It waits the writer out instead of failing at the first collision, which is what
busy_timeoutwas always supposed to do.The
xfailthat pinned the old defect is gone:test_the_pass_does_not_publish_foreign_work_earlypasses, because a pass that never touches the shared connection cannot publish or discard anyone else's work. Three mutations (no close, no commit, shared connection) all turn the suite red. Suite 2336 passed. -
Every synchronous connection in
features/andshared/now closes (2026-10-17).with sqlite3.connect(...)is a transaction context manager:__exit__commits or rolls back and leaves the connection open. In a script the garbage collector hides that; in the autohooks daemon, which serves every event for hours, it is one descriptor per call, forever.Measured, not inferred:
with sqlite3.connect(...) x 50 -> +50 descriptors still open (gc disabled) disclosure.list_rules() x 20 -> +40 descriptors (db + wal per call) live daemon, 10 min after start -> 29 descriptors on memory.db, growingThose unclosed connections are also what makes
database is lockedreach other writers: a connection left mid-transaction holds the write lock, and the next writer waits outbusy_timeoutand fails. The same shape was proven on the live base — a killed orphan holdingmemory.dbmadeBEGIN IMMEDIATEfail for 2 s from outside whileCHECKPOINTstayed free; after it was killed the identical probe returned in 0.1 ms.33 call sites across
features/{semantic_audit,disclosure,rehydrate,counterfactual,reflection,diagnostics,diff,scratchpad,stash,procedures}.py,shared/door_log.pyandshared/read_only.pynow useshared/sync_db.py::open_db, which preserves the transaction semantics (commit on a clean exit) and adds the missingclose().No explicit
rollback()in the error path: closing a connection with an open transaction rolls it back, and a mutation that removed the explicit rollback survived the whole suite — there was nothing left for it to change. The comment in the helper records that, so it is not "fixed" back in.Red-first: reverting one call site makes the new tests report
assert 40 == 0. Three mutations (no close, no commit, site reverted) all turn the suite red. Suite 2335 passed, 1 xfailed. -
Reverted the parts of #129 that could touch another component's transaction (2026-10-17). #129 added a
commit()per user and arollback()in the error path to_rescore_pass. Both run on the connection every tool handler shares, so both reach outside the pass:handler wrote, not committed -> the pass's commit() -> published to other readers handler wrote, not committed -> the pass's rollback() -> the handler's row is goneMeasured on a throwaway base: a row a handler had written and not committed appeared to a second connection after the pass's commit, and a
core_memoryrow vanished on the pass's rollback. A pass on a borrowed connection owns no transaction boundary; moving that boundary is not the fix, and losing a handler's row is worse than losing a sweep.Also reverted: the retry around the write. When the connection holds a stale read snapshot, SQLite answers BUSY in well under a millisecond and never consults
busy_timeout, so repeating the statement cannot succeed — the transaction has to be abandoned. All the retry did was turn one 15 s wait into four, holding the single aiosqlite worker and stalling tool handlers for the duration.Kept, because neither reaches outside the pass and both were measured to help: the user list is materialised instead of iterated (a cursor held across the writes is what makes the write upgrade a stale snapshot), and the pass logs
importance sweep: rescored=N skipped=N errors=N users=N— before it, twelve rescued rows and total failure looked identical.What this does not do: it does not fix the failure. #129 claimed to remove a class of failure; that claim did not survive the evidence — a process on the merged code logged
errors=1on ten consecutive passes, and only a restart cleared it. The remaining evidence points inside the process (fresh process clean, aged process failing every pass), not at a rival writer. Recorded as an xfail test rather than a comment, so the next change has to face it:xfail test_the_pass_does_not_publish_foreign_work_earlySuite 2323 passed, 1 xfailed.
-
The rescore sweep failed on a competing writer, and one collision cost the whole pass (2026-10-16). Every 30 minutes the sweep logged
rescore for user=default failed— 106 occurrences over four days, at :16 and :46, without missing a beat — and each one waslifecycle/importance_scheduler.py:186 in _rescore_user aiosqlite/core.py:223 in execute OperationalError: database is lockedLine 186 is
UPDATE core_memory. Three shapes made it fatal:- the user list was read with
fetchmanyand the cursor stayed open across every write. SQLite cannot upgrade a read snapshot to a write once another connection has committed, and it answers BUSY without consultingbusy_timeout— which is why the error arrived in a tenth of a millisecond with a 15-second timeout configured, and why the timeout never helped; - one
commit()for the entire pass, covering hundreds of statements: a single collision discarded every row the pass had already rescored, and holding a write transaction that long is what makes the collision likely; - no retry, and no result line — a pass that rescued twelve rows and one that failed on every user produced identical silence, so the only trace was a traceback.
The user list is now materialised before any write, each user is committed on its own, a lock error is retried with backoff, and every pass logs
importance sweep: rescored=N skipped=N errors=N users=N.What the measurement established, and what it did not, matters here. Established: the failing statement and both of its stack frames; the cadence (106 failures, gaps of exactly 1800 s); and that
VACUUMis ~1 s and not the trigger. Not established: which competing writer holds the file. Four candidates were tested and refuted — an undrained cursor, an open snapshot being upgraded, a second process's VACUUM, and plain concurrent writes while the pass ran.Corrected on 17.10: this entry first said the change "removes the class of failure". It does not. A process on the merged code logged
errors=1on ten consecutive passes, and only a restart cleared it; a fresh process is clean, an aged one fails every pass. The claim was withdrawn and the entry above records the revert and the remaining evidence. A plausible mechanism is not a fix.Red-first, four tests, each failing on the previous code and passing on this one:
- a write must not be issued while a read cursor is open;
- a BUSY write must be retried rather than losing the user;
- the pass must report what it did;
- an interrupted pass must keep what it already committed.
Two of those tests passed against the defect when first written and had to be rewritten — one tracked the last cursor instead of the open ones, the other read its assertion through the same connection that held the uncommitted work. Both are noted in the tests, because a test that cannot fail is the defect it was meant to catch.
Suite 2324 passed (was 2320).
- the user list was read with
-
The dispatch log held one database descriptor per event (2026-10-16). Every dispatched event writes a
memory_dispatch_logrow, and it did so throughwith sqlite3.connect(str(db_path)) as _conn: ... _conn.commit()
which is a TRANSACTION context manager: it commits, it does not close. Measured with the collector disabled, that shape leaves exactly one descriptor on the database file per call — three hundred blocks left three hundred descriptors, 1:1 — and the connection object is only freed by a collection, so with the collector enabled the count drifts upward instead of returning to zero.
The shape appears 37 times in production code (30 in
features/, 5 inshared/, 1 in this hook), and the temptation is to convert all of them. The measurement says otherwise:MCP server, 21 hours up -> 3 descriptors = one healthy WAL connection. Not leaking. autohooks daemon, 3h39m up -> 46 descriptors on one database, against 9 threads.Same code, different exposure: the daemon runs the dispatch path once per event, the server does not. The sites in
features/accumulated nothing in 21 hours, so they are left alone — converting 34 things that measurably do not leak is work by resemblance, not by evidence._write_dispatch_rowwrites the row on a connection closed in afinally, and the lazytext_previewmigration now does the same.The test needs
gc.disable(), and that is the part worth recording: with the collector running it cannot see this defect at all — the connections are freed between the two readings, so the counts match with the bug present. The first mutation run said so twice over, since the migration's own close survived because the test collected before the reading.Mutation-checked, three mutations, zero survivors: the writer never closes (M1), the migration never closes (M2), the row is never committed (M3).
Suite 2320 passed.
-
The WAL checkpoint ran on the shared connection, so it failed exactly when the process was busy (2026-10-16).
wal_checkpoint(TRUNCATE)executed on the connection every MCP tool handler shares, and everyawaitin a handler is a yield point:cur = await conn.execute("SELECT ... FROM some_table") rows = await cur.fetchall() # <-- the maintenance pass can run in this gapA checkpoint landing in that gap finds a statement actively stepping and fails with
sqlite3.OperationalError: database table is lockedThat is not
database is locked(another writer) — it is a same-connection error, and it is why the live checkpoint failed 7 times out of 29 while reporting nothing usable, and why 11–61 MB of WAL sat unclaimed on a base whose readers are all brief.Established by measurement, having first refuted two plausible candidates: an undrained cursor is fine (
DELETE+fetchall+commit→ checkpoint ok), and no maintenance step leaves a transaction open (an instrumented pass on a copy reportedin_transaction=Falseafter every step). What breaks it is an in-flight statement in another task on the same connection:same connection, statement in flight -> OperationalError: database table is locked its own connection, same moment -> ok, busy=1 log=59 done=59On its own connection the same call answers
busy=1— SQLite's ordinary "a reader holds the WAL, ask again later". That is precisely what the retry loop was written to handle, and it could never handle anything while every attempt raised instead. So this does not add a feature; it makes an existing one reachable._run_checkpointopens a short-lived dedicated connection throughasyncio.to_threadand closes it in afinally. Thebusyflag stays authoritative: a smaller file is not proof that this call reclaimed it.Two mutation findings worth recording, both about tests rather than code:
- an earlier version of the descriptor test compared counts after 5 and after 20 calls and stayed green with
finally: close()removed, because CPython's automatic cycle collection had run in between. Measured: an unclosed sqlite3 connection holds its descriptor until a GC pass (+1right after the call,0aftergc.collect()) — refcounting alone does not free it. The test now runs withgc.disable(), where a missing close leaks one per call; - the
busyguard survived every test until one simulated the single state it exists for — a journal that shrank while SQLite reported BUSY — by truncating the file directly. Before that it read as dead code.
Mutation-checked, four mutations, zero survivors: back on the shared connection (M1), the dedicated connection never closed (M2), the exception no longer caught (M3), and
busyignored (M4).Suite 2311 passed.
- an earlier version of the descriptor test compared counts after 5 and after 20 calls and stayed green with
-
The descriptor count charged two per healthy connection, so the reading it was built to produce could never be zero (2026-10-16). The count was
".db" in target, which a WAL-mode connection satisfies three times: it holdsmemory.db,memory.db-walandmemory.db-shm. Measured on one managed connection doing nothing wrong:connections=1 dbs=['memory.db'] open_db_fds=3 unmanaged_fds=2unmanaged_fdswas therefore never 0 on a healthy base — the finding this reading exists to surface was buried under a constant that says nothing. Worse, the test written against the live defect asserted it:unmanaged_fds >= 3was described as "the finding", when three of those descriptors were the journal the connection is supposed to hold.The rule now counts the database file itself: the basename must end in
.dbafter a(deleted)suffix is stripped. Measured on the same connection: 3 descriptors under the old rule, 1 under this one,unmanaged_fds=0. The report that prompted this — 41 descriptors on one file,unmanaged_fds=40— is unchanged, because those are descriptors onmemory.db;-waland-shmwere counted separately (10 and 1). So the noise is gone and the signal is intact.The
(deleted)suffix is not decoration: a rotated database keeps its descriptor and reads<path> (deleted), and it is still a database this process holds open.Mutation-checked, four mutations, zero survivors: the old rule restored (M1), a deleted database not counted (M2), the
base_dirguard dropped so every.dbin the process counts (M3), and an unreadable table reported as 0 instead of the-1sentinel (M4). M3 survived the first run and a test was added for it — without that guard the reading moves with other houses' bases and any scratch file left open anywhere on the machine. -
Every process served as its own maintenance writer, and the collision surfaced as a failed connection (2026-10-14).
lifespanstarts housekeeping on every start —migrate(),backup_cron,importance_scheduler, and a 15-minuteforgetting_system.cleanup()— and nothing coordinated the processes doing it. Onlybackup_cronheld a lock (.backup_cron.lock); the rest ran in every instance.The cost was measured on the live hermes base: the MCP server came up eleven times in one day,
backup_cronlogged twice in one minute, andDecay failedwithsqlite3.OperationalError: database is lockedwent 2-4/day in September to 14 on 12.10, 65 on 13.10, 34 by midday on 14.10. The WAL reached 35 MB without ever checkpointing, andwal_autocheckpoint=1000cannot fire while another process always holds a read snapshot.Worse than the wasted work:
migrate()calledalembic_command.upgrade(cfg, "head")unconditionally, so every start opened a write transaction just to learn the version was already current — andlifespanruns it before the server answers anything. Queued behind another writer, it outlasted the client'sconnect_timeoutand four sessions came up with no memory tools at all:12.10 16:03 · 13.10 09:02 · 14.10 07:02 · 14.10 08:02 Failed to connect to MCP server 'ariel-memory': CancelledErrorThe server was healthy throughout — it logged its own start five seconds in. The client had already given up.
Two changes.
shared.maintenance_lockgives the housekeeping the disciplinebackup_cronalready had: a non-blockingflockon<base_dir>/.maintenance.lock, taken around the work rather than the process, soimportance_schedulerand the 15-minute cleanup take turns instead of colliding, and a process that cannot claim it waits for its next turn rather than queueing behind the leader. Andmigrate()now compares the current version against the Alembic script heads first — a comparison that needs no database connection — so the steady state does no write at all and cannot block a startup. -
An expired nightly budget started a second full pass on top of the first (2026-10-14).
NIGHTLY_LAYER_BUDGET_Swas 180 s. Measured over 12 runs ofuser graph_enrichon the live hermes base: p50 151 s, p90 236 s, p95 324 s, max 426 s — so four runs of twelve did not fit.The timeout is not what it looks like:
_await_on_main_loop(...).result(timeout=)abandons the coroutine rather than cancelling it, so a pass that outran its budget is still running. But the pass was reported"failed", and because the cycle is recorded only after both layers finish it was never recorded — so the scheduler came back 15 minutes later and started the whole pass again. Measured in one evening:14:37:41 Nightly pass: layer=user starting 14:40:41 finished in 180.0 s <- budget expired 14:44:46 graph_enrich 425773 ms <- the work was STILL GOING 14:57:41 Nightly pass: layer=user starting <- a second pass, because the cycle stayed open 15:00:41 finished in 180.0 s <- and it expired againTwo passes over one graph is what the observed
database is lockedwas made of — measured live at 14:44:46, when the periodic maintenance task tried to write while that pass still held the base. The same lock, held by a long-running writer, is the mechanism behind themigrate()timeouts that left four sessions without memory tools on 12.10–14.10; those four are a separate claim, and the nightly rework is not offered as their proof.Two changes. The budget becomes 600 s, covering the measured maximum by ~1.4x and written down as a measurement rather than a number that gets raised whenever it trips. And the pass gains a fourth verdict: a
TimeoutErrorwith live futures in_pending_futuresis"running", not"failed"— the same test_nightly_busyalready uses to tell "still going" from "died"."running"charges no backoff (a retry would stack a second pass on the first) and resets no failure counter (the outcome is not known yet); the existing_nightly_busyguard is what prevents the overlap.A real failure still reports
"failed": the hook raising with no live futures has no work to wait for, andtest_failed_layer_does_not_record_the_cycle_as_donekeeps that path pinned. Verified by mutation — restoring 180 s, ignoring live futures, and charging"running"a backoff each kill the test that covers them. Suite 2273 passed. -
Lint was hiding a shadowed test class, and the ignore that hid it was justified by a claim that was no longer true (2026-10-14).
F811(redefinition-of-unused) sat in the global ignore list, commented "common in tests". Enabling it finds five redefinitions, and four are in production code:features/staging.py:164 logging <- `import logging` twice, two lines apart lifecycle/graph_enrich.py:242 time <- re-imported mid-file, already at :14 mcp_server/tools/ops.py:650 connection_manager <- re-imported mid-file, already at :44 alembic/versions/e5b7d2f8a1c3 Sequence <- duplicate import in the revision header tests/test_hypothesis.py:440 TestSecretsPropertiesThe test one is the expensive one. The file carried the same banner and the same class twice — 37 lines, a verbatim copy. The second definition shadowed the first, so the first never ran, and because no test failed nobody ever looked. The duplicate had no import of its own: it worked only because the original's
from features.secrets import …sits at module level, which is what a merge artefact looks like.Removed, and the removal is checked by collection count: 79 tests before, 79 after — the duplicate was dead weight, so coverage cannot move.
F811is now enabled repo-wide, and the mutation check proves the rule actually fires rather than merely being un-ignored. -
Documentation promised three settings that do nothing (2026-10-14).
config.yamldocuments thatl2_session_limit,l3_episodic_limitandl4_core_limitare read by no code — butdocs/getting-started/configuration.mdpresented all three with comments describing what they hold ("SessionStore sessions", "EpisodicMemory episodes", "CoreMemory facts"), andopenwiki/quickstart.mdlisted them as tier limits without qualification. An operator could set them, watch nothing happen, and have no way to find out.The docs now say the same thing the config comment says, and
tests/test_config_declared_vs_read.pykeeps the two in agreement in both directions: a key the comment calls inert must have no reader, and a key it calls live must have one. Either side going stale fails the test, so the claim cannot drift back into being wrong — which is how it was wrong the first time. -
The base had two locks, so two writers could hold it at once (2026-10-14).
.backup_cron.lockguards the nightly pass and the backup;.maintenance.lockguards the 15-minute cleanup and the importance sweep. Neither sees the other as taken, so a nightly pass and a cleanup could write the same SQLite file simultaneously.Measured, not inferred — 14.10.2026, 14:44:46:
14:37:41 Nightly pass: layer=user starting 14:40:41 finished in 180.0 s <- layer budget expired; the work was abandoned, NOT cancelled 14:44:46 graph_enrich 425773 ms <- that abandoned work was still writing 14:44:46 mcp_server.lifespan: Periodic task error -> consolidate_episodes -> database is lockedThe nightly pass now takes the maintenance lock as well, around the whole pass rather than only the layer hooks: the compact, sweep, replay, expiry, tiering, enrich and bridge steps that follow all write too.
Two details that are the difference between fixing this and appearing to:
The lock outlives the pass when work was abandoned. A
TimeoutErrormeans the layer budget expired while the hook kept running, so releasing the lock at that point would put the cleanup straight back into the collision it exists to prevent. The lock is held and handed back by the abandoned work's own completion callback. And if_nightly_busylater gives up on work that never finished, the lock goes back with it — a lock held for work nobody is waiting for would keep housekeeping out until the process restarted.A skipped pass is not a success. If the maintenance lock cannot be taken within
NIGHTLY_MAINTENANCE_WAIT_S(120 s), the pass reports"skipped"and leaves the failure ladder as it was. Resetting the counter there would make a pass failing behind a busy base retry every 15 minutes forever instead of backing off — and the base is busy precisely when retrying is least welcome.maintenance_lock.acquiregains a boundedtimeout(default stays non-blocking, and the default is what the scheduler relies on). It polls rather than blocking inflock, because a blocking acquire is uninterruptible and the caller's own budget should decide when to give up. The wait exists because skipping has a cost: a pass that does not run leaves the cycle unrecorded, which is how the scheduler came to start a second full pass on top of the first.RED first: six tests failed on the old code, each for its stated reason — no
timeoutparameter, no lock held during the work, and the lock released while abandoned work was still writing. Mutation-checked: dropping the lock (M1), releasing it during abandoned work (M2), not releasing it on grace expiry (M3), ignoring the wait (M4), waiting forever (M5) and treating"skipped"as success (M6) each kill the test that covers them. Suite 2284 passed. -
One piece of work, two processes, one lock (2026-10-15). The same defect as the two-locks bug above, one level further in.
forgetting_ritual— decay plus compression — is registered on theforgetting_ritualhook by bothhooks/agent_hooks.py:170andhooks/user_hooks.py:139, so the autohooks daemon runs it while the MCP server runs the same two operations from its own 15-minute_periodic_tasks. Only the server took the maintenance lock; the ritual took none.Measured on the live hermes base, night of 14–15.10.2026: 106
database is lockedtracebacks, one every fifteen minutes, all of them fromlifecycle/forgetting.py:183(decay_importance→executemany). Not the occasional race the earlier fixes had reduced it to — every ritual attempt failed, interval after interval, because another writer was always inside. The write lock itself was free whenever probed from outside: the collisions were exactly as long as the two writers' overlap.forgetting_ritualnow takes the maintenance lock for the whole ritual, both routes through it (the staged path and the plainfs.cleanup()taken whenstaging.enabledis false — they write the same base, so both are guarded). Losing the race skips rather than waits: both operations are idempotent, so the next round catches what this one missed, and waiting would hold a connection open while another process is trying to write. The skip is reported ({"skipped": "maintenance_lock"}) rather than silently returning zeroes, so a base that never gets its round is visible in the log.RED first: two tests failed on the old code with
the plain cleanup ran while another writer held the base. Mutation-checked — five mutations, zero survivors: no lock (M1), never released (M2), silent skip (M3), wait instead of skip (M4), and skip declared after the work (M5).The first mutation run was itself wrong, and that is worth recording: M2 and M4 "survived" because they were aimed at tests that could not observe them. The never-release mutation was run against the holder test, where the lock is refused and the code returns before the
try/finally, so the release never executes; only the leader test can see it. The wait mutation was run against a holder that never lets go, where a timeout and no timeout both returnNone. A surviving mutation is a hypothesis about the test, not a verdict on it — check that the mutated line is reachable in the test you aimed at before concluding a guard is toothless. -
The WAL was never truncated, because the only TRUNCATE sat behind a VACUUM (2026-10-15).
features/db_maintenance.pyheld the codebase's onlyPRAGMA wal_checkpoint(TRUNCATE), and it ran inside_vacuum_if_fragmented— which returns before reaching it unlessfreelist / page_count >= 0.25. A base with no dead pages therefore never truncated its WAL at all. The live hermes base hasfreelist_count = 0over 26 730 pages, and VACUUM has fired once in the entire log history (19.09, 136 MB → 16 MB) — so the checkpoint almost never ran.Measured:
memory.db103.93 MB with a 61.04 MB WAL, unchanged across two restarts. Not runaway growth —journal_size_limitcaps it at 64 MiB, which is why it sat at its ceiling looking stable. What it cost was 61 MB of disk held for nothing and a crash-recovery step that would have to replay it.A PASSIVE checkpoint is not the answer, and measuring that is part of the fix: with a lone connection it moved all frames into the database and left the WAL file at 12.61 MB, unchanged. Moving pages is not returning space. Only TRUNCATE does that.
checkpoint_walis now its own step inrun_db_maintenance(hourly), outside the fragmentation gate, scoped to the same two databases the VACUUM already restricted itself to. It takes the file size, not the PRAGMA's return value, as the truth about what came back.The first version of it stopped there, and the hourly pass proved it wrong on the live base: after two runs the WAL was still 61 MB and the log said nothing. Reproducing it on a copy showed why — with another connection holding a read snapshot the PRAGMA does not return
busy=1, it raisessqlite3.OperationalError: database table is locked, which the first version caught, logged at DEBUG and turned into a 0.0. The live config is INFO, so a base whose WAL never shrank looked exactly like one with nothing to shrink — the same silent no-op this whole entry is about, one level down.So it retries (4 attempts, 0.5 s apart — the readers here are brief, which is what the retries are for) and it speaks: a WAL of 5 MB or more that survives every attempt produces a warning naming the reason and the size left behind. Smaller journals stay silent, because an alarm that fires every hour regardless is the defect just removed from
db_warn_mb.Proven on the live base with other processes running, and on a copy of it:
live base, other processes up copy of the live base, real WAL before db 105.81 MB wal 4.25 MB before db 104.41 MB wal 61.04 MB after db 105.82 MB wal 0.00 MB after db 104.41 MB wal 0.00 MBRED first for this part:
a failed truncation of a 12.6 MB WAL produced no warning, and a reader releasing after 0.3 s still cost the whole truncation. Mutation-checked, four mutations, zero survivors — one attempt instead of four, silence on failure, a warning threshold of zero, and a retry loop that never retries.RED first:
WAL was 12.6 MB before and 12.6 MB after maintenance; with no dead pages the checkpoint is unreachable, so the file never shrinks. Mutation-checked, four mutations, zero survivors — no call from maintenance (M1), PASSIVE instead of TRUNCATE (M2), swallowed exception removed (M3), and the ownership gate dropped so foreign databases are touched (M4).M2 survived its first run for the reason this session keeps re-learning: the anchor
'PRAGMA wal_checkpoint(TRUNCATE)'appears twice in the file — once inside_vacuum_if_fragmented, once in the new function — andreplace(..., 1)hit the first. The mutation never reached the code under test. Aim each mutation at the exact occurrence it means to change. -
A stale connection was dropped without being closed, and kept its locks for the life of the process (2026-10-15).
AsyncConnectionManager.get()treats a connection as stale whenSELECT 1fails on it, and then did onlyself._conns.pop(db_name, None). The connection object left the dict and nothing else happened to it.That is not enough, because
_get_aiosqlite_connappends every connection to_tracked_aiosqlite_conns— a strong list, deliberately, as its own comment records ("a WeakSet would lose it", since the aiosqlite worker thread keeps only the tx queue alive). A dropped connection is therefore not collected. If it was mid-transaction when it went stale, it goes on holding that transaction's locks, and nothing can reach it any more to release them: the manager no longer references it, so noclose_all()will ever touch it. Killing the process is the only way out.Measured on the live hermes base: decay ran clean from 09:00 to 16:02, then failed
sqlite3.OperationalError: database is lockedten times in a row over two hours (16:17 → 18:33, zero successes) while the WAL grew 2 → 14.5 MB. ProbingBEGIN IMMEDIATEfrom outside the process returned free eight times out of eight, at 0.3 ms — the lock was held inside, which is what a leaked connection looks like from anywhere else. A daemon restart cleared it immediately, consistent with the connection being unreachable.The stale entry is now closed, best-effort (
contextlib.suppress, because a manager that raises here would break every caller). Thepopkeeps its original intent: the entry is only touched if it is still the object that was judged stale, so a replacement installed by a concurrent getter is neither evicted nor closed.RED first, and this time the failing test reproduced production's own error message:
a fresh connection could not write while the dropped one held a transaction: database is locked.Mutation-checked, three mutations, zero survivors — drop without closing (M1), remove the identity guard (M2), and stop suppressing a failing
close()(M3). M2 is worth recording: it survived the first run because two earlier versions of its test asserted the wrong thing —not replacement.closedpasses even with the guard gone (the code closesconn, never the replacement), and asserting the dict still holds the replacement fails on correct code becauseget()installs a fresh connection over it. The real property is that the replacement is not evicted, and the test now recordspopcalls to check exactly that. A third version had to be fixed too:dict.popcannot be assigned on an instance (AttributeError: 'dict' object attribute 'pop' is read-only), and the test was erroring rather than asserting, which made the mutation run look green for the wrong reason.Suite 2301 passed (was 2297).
ruff check,ruff format --checkandmypyover the CI's directory list are clean.