Skip to content

v1.13.0

Latest

Choose a tag to compare

@github-actions github-actions released this 18 Oct 21:11
· 31 commits to master since this release
c267bf1

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() and leaked_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 logs

    connection health: connections=N dbs=... leaked_workers=N
    

    so the next storm has a reading taken moments before it rather than after the fact.

    _storage_pass is 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_health drives 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 -1 rather 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() and leaked_connection_workers() both report what the ASYNC manager owns; they are blind to synchronous handles, and with 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=0 while 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, with unmanaged_fds as 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, not 0 — the same sentinel rule as before: a failed reading must not look like a clean bill of health.

    test_daemon_logs_connection_health drives run_daemon rather 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_fds not 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 aiosqlite connection. 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 with SQLITE_BUSY_SNAPSHOT (517), surfacing as database is locked despite busy_timeout=15000, because the busy handler is never consulted for 517. The failure then cascaded: decay failed, prune_dangling_edges raised, 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 flock per base. None of them removed the shared connection from the reach of an ordinary caller, so the class stayed open.

    shared/write_dispatch.py closes it. WriteDispatch is the sole owner of a private connector built from the manager's base_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), with query / select_one / scalar / write / insert / write_many / atomic / execute_script. atomic runs a read-then-write callback under the write lock and is re-entrant per task through a ContextVar, 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.py keeps its hash-chain transaction (head read, digest, insert, hash update) as a single atomic callback, with BEGIN IMMEDIATE retained 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_transaction guards and the _BUSY_ERRORS catches stay. They still defend against a foreign transaction arriving through AsyncConnectionManager.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, -shm byte 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-wal fell from a frozen 4.86 MB to 0.1 MB), and three consecutive passes on each of the three deployed bases logged Decayed / WAL checkpoint / connection health ... leaked_workers=0 with zero database is locked lines. 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_embedding and wire_new_node committed inside their own @_maintenance wrapper, which already owns the transaction, so they published half of it; lateral_inhibition committed the connection passed to it by _insert_edge. Proven behaviourally — an edge written by wire_new_node survived an outer atomic rollback — 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 conn and 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 whose base_dir had 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_conn ran 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 ms
    

    auto_vacuum was 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, because busy_timeout was applied after the statement that blocked. At startup that is the Failed to connect line. Moving busy_timeout first 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 locked after 15.9 s. Three mutations (no tolerance, no timeout restore, unconditional auto_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 connection
    

    Now _rescore_pass opens a short-lived sqlite3 connection in asyncio.to_thread, the same shape #127 gave the WAL checkpoint: it inherits nobody's transaction, busy_timeout applies 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=0
    

    It waits the writer out instead of failing at the first collision, which is what busy_timeout was always supposed to do.

    The xfail that pinned the old defect is gone: test_the_pass_does_not_publish_foreign_work_early passes, 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/ and shared/ 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, growing
    

    Those unclosed connections are also what makes database is locked reach other writers: a connection left mid-transaction holds the write lock, and the next writer waits out busy_timeout and fails. The same shape was proven on the live base — a killed orphan holding memory.db made BEGIN IMMEDIATE fail for 2 s from outside while CHECKPOINT stayed 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.py and shared/read_only.py now use shared/sync_db.py::open_db, which preserves the transaction semantics (commit on a clean exit) and adds the missing close().

    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 a rollback() 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 gone
    

    Measured 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_memory row 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=1 on 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_early
    

    Suite 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 was

    lifecycle/importance_scheduler.py:186 in _rescore_user
    aiosqlite/core.py:223 in execute
    OperationalError: database is locked
    

    Line 186 is UPDATE core_memory. Three shapes made it fatal:

    • the user list was read with fetchmany and 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 consulting busy_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 VACUUM is ~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=1 on 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 dispatch log held one database descriptor per event (2026-10-16). Every dispatched event writes a memory_dispatch_log row, and it did so through

    with 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 in shared/, 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_row writes the row on a connection closed in a finally, and the lazy text_preview migration 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 every await in 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 gap
    

    A checkpoint landing in that gap finds a statement actively stepping and fails with

    sqlite3.OperationalError: database table is locked
    

    That 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 reported in_transaction=False after 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=59
    

    On 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_checkpoint opens a short-lived dedicated connection through asyncio.to_thread and closes it in a finally. The busy flag 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 (+1 right after the call, 0 after gc.collect()) — refcounting alone does not free it. The test now runs with gc.disable(), where a missing close leaks one per call;
    • the busy guard 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 busy ignored (M4).

    Suite 2311 passed.

  • 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 holds memory.db, memory.db-wal and memory.db-shm. Measured on one managed connection doing nothing wrong:

    connections=1 dbs=['memory.db'] open_db_fds=3 unmanaged_fds=2
    

    unmanaged_fds was 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 >= 3 was 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 .db after 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 on memory.db; -wal and -shm were 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_dir guard dropped so every .db in the process counts (M3), and an unreadable table reported as 0 instead of the -1 sentinel (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). lifespan starts housekeeping on every start — migrate(), backup_cron, importance_scheduler, and a 15-minute forgetting_system.cleanup() — and nothing coordinated the processes doing it. Only backup_cron held 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_cron logged twice in one minute, and Decay failed with sqlite3.OperationalError: database is locked went 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, and wal_autocheckpoint=1000 cannot fire while another process always holds a read snapshot.

    Worse than the wasted work: migrate() called alembic_command.upgrade(cfg, "head") unconditionally, so every start opened a write transaction just to learn the version was already current — and lifespan runs it before the server answers anything. Queued behind another writer, it outlasted the client's connect_timeout and 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': CancelledError
    

    The server was healthy throughout — it logged its own start five seconds in. The client had already given up.

    Two changes. shared.maintenance_lock gives the housekeeping the discipline backup_cron already had: a non-blocking flock on <base_dir>/.maintenance.lock, taken around the work rather than the process, so importance_scheduler and 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. And migrate() 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_S was 180 s. Measured over 12 runs of user graph_enrich on 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 again
    

    Two passes over one graph is what the observed database is locked was 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 the migrate() 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 TimeoutError with live futures in _pending_futures is "running", not "failed" — the same test _nightly_busy already 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_busy guard is what prevents the overlap.

    A real failure still reports "failed": the hook raising with no live futures has no work to wait for, and test_failed_layer_does_not_record_the_cycle_as_done keeps 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   TestSecretsProperties
    

    The 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. F811 is 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.yaml documents that l2_session_limit, l3_episodic_limit and l4_core_limit are read by no code — but docs/getting-started/configuration.md presented all three with comments describing what they hold ("SessionStore sessions", "EpisodicMemory episodes", "CoreMemory facts"), and openwiki/quickstart.md listed 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.py keeps 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.lock guards the nightly pass and the backup; .maintenance.lock guards 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 locked
    

    The 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 TimeoutError means 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_busy later 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.acquire gains a bounded timeout (default stays non-blocking, and the default is what the scheduler relies on). It polls rather than blocking in flock, 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 timeout parameter, 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 the forgetting_ritual hook by both hooks/agent_hooks.py:170 and hooks/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 locked tracebacks, one every fifteen minutes, all of them from lifecycle/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_ritual now takes the maintenance lock for the whole ritual, both routes through it (the staged path and the plain fs.cleanup() taken when staging.enabled is 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 return None. 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.py held the codebase's only PRAGMA wal_checkpoint(TRUNCATE), and it ran inside _vacuum_if_fragmented — which returns before reaching it unless freelist / page_count >= 0.25. A base with no dead pages therefore never truncated its WAL at all. The live hermes base has freelist_count = 0 over 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.db 103.93 MB with a 61.04 MB WAL, unchanged across two restarts. Not runaway growth — journal_size_limit caps 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_wal is now its own step in run_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 raises sqlite3.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 MB
    

    RED 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 — and replace(..., 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 when SELECT 1 fails on it, and then did only self._conns.pop(db_name, None). The connection object left the dict and nothing else happened to it.

    That is not enough, because _get_aiosqlite_conn appends 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 no close_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 locked ten times in a row over two hours (16:17 → 18:33, zero successes) while the WAL grew 2 → 14.5 MB. Probing BEGIN IMMEDIATE from 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). The pop keeps 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.closed passes even with the guard gone (the code closes conn, never the replacement), and asserting the dict still holds the replacement fails on correct code because get() installs a fresh connection over it. The real property is that the replacement is not evicted, and the test now records pop calls to check exactly that. A third version had to be fixed too: dict.pop cannot 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 --check and mypy over the CI's directory list are clean.