cd /news/ai-agents/a-failed-sqlite-integrity-check-on-w… · home › topics › ai-agents › article
[ARTICLE · art-147649] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↓ negative

A failed SQLite integrity check on Windows that reported itself as a POSIX lock conflict

A YantrikDB maintainer traced a Windows 11 write failure in the Hermes Agent install to a mislabeled error: the engine's ForeignSqliteGuard is inert on Windows, so the only path that could taint the store was a failed PRAGMA quick_check(1) reported as an in-process foreign SQLite library. The misleading message advised closing a connection that could not exist, and with no reopen() or recover() API, restarting the host application was the only working recovery while the WAL grew to 6,344,832 bytes. The maintainer documented the diagnosis and the set_foreign_sqlite_mode("warn") escape hatch that resumes writes without re-checking integrity.

by read7 min views2 publishedOct 8, 2026

For several hours on 2026-09-30, every memory write from a Hermes Agent install on a Windows 11 machine failed with this: YantrikDB unavailable: remember failed: refusing to write: another SQLite library has C:/Users/<user>/AppData/Local/hermes/yantrikdb-memory-ml.db open in this process (issue #225 - POSIX locks are per process, so its unlock releases the engine's and the two writers would interleave WAL commits). Close that connection, check integrity and reopen the engine (its close may have unlinked the shared-memory file); use the engine API / a separate process for raw SQL.

The engine refusing those writes was YantrikDB 0.23.1, a persistent memory engine for AI agents that I maintain (Rust core, Python bindings on PyPI). @pawelsu8 filed issue #247 the same day, from Windows 11 build 26200, CPython 3.14.7 and stdlib sqlite3 3.53.1. The title is long, and every clause in it turned out to be accurate: "ForeignSqliteInstance latches writes off on Windows, where the foreign-instance detector is inert: a failed quick_check taints the store but reports as an in-process foreign SQLite library, with no in-process recovery."

The error is about POSIX advisory locks. They belong to the whole process, so if another SQLite library in the same process closes the same file, its unlock releases the engine's locks as well. That's the scenario the engine's in-process foreign-SQLite detector, ForeignSqliteGuard, looks for.

On Windows the detector does nothing. ForeignSqliteGuard::new sets shm_path = None on any target that isn't Linux or macOS, so supported() returns false and scan() returns immediately. The module's doc comment says so: "Windows locks are per handle and does not have the problem; there the detector reports supported = false and the mode is inert."

@pawelsu8 checked that against the running system. He dumped stats() on the affected machine and got foreign_sqlite_supported: false, foreign_sqlite_active: false, foreign_sqlite_tainted: false. Then he opened the same store with Python's stdlib sqlite3, read-only and read-write, with and without commits, and none of it ever set active or tainted. The detector was inert exactly as documented, which meant something else was refusing his writes.

check_write() doesn't look at the active flag the detector sets. It refuses on tainted, and tainted has a second writer with no platform gate: note_integrity(). The background materializer tracks SQLite's PRAGMA data_version, and when it notices a commit from outside the engine it runs PRAGMA quick_check(1) on a read connection and hands the result to note_integrity(). A failed check sets tainted.

On Windows that's the only thing that can set it, so every refusal of this kind on that platform was a failed integrity check. Both causes raised the same error type, ForeignSqliteInstance, with the same fixed message. That message described the cause that was impossible on Windows and told him to close a connection that couldn't exist. What had actually happened, a check saying the database might be corrupt, wasn't mentioned anywhere. He called that "a far more serious thing to be told about," and he was right.

The Python object exposes close(), but there was no reopen() or recover(). Opening the store from a second process got "database is locked." So the message's own advice, reopen the engine, couldn't be followed from inside the running host or from outside it. Restarting the whole application was the only thing that worked.

Reads kept succeeding the whole time, so nothing looked broken. Refused writes meant no commits, no commits meant no checkpoint, and the WAL file grew to 6,344,832 bytes next to a 6,307,840-byte database.

There was one more exit, which he found by reading the source: set_foreign_sqlite_mode("warn"). It resumes writes without re-checking integrity, and the setting persists, so every future open of that store runs with both refusal mechanisms disarmed. He used it cautiously and flagged it in the issue as worse than the docs make it look.

I confirmed the trace and marked it as a bug. My reply opened with: "Thanks for this one — tracing check_write down to decide() and pulling the stats() flags dump to confirm supported=false empirically is the kind of report that gets acted on fast."

The fix is commit 33d0be9, 15 files changed, +404/-45, released as v0.23.2 (tagged 2026-10-02T07:04:44Z; pip install -U yantrikdb). Most of it maps directly onto the numbered requests at the end of his issue:

IntegrityCheckFailed error, whose message carries the actual quick_check result. ForeignSqliteInstance is now raised only by the in-process detector, so the Windows message above can't be produced at all anymore.stats() shows the cause too. There are new integrity_tainted and integrity_checks_unconfirmed_since_boot keys, and foreign_sqlite_tainted now reflects the detector alone.ok resumes writes with no restart. A repair made from another process is itself an outside commit, so the engine notices it and re-checks on its own. The release note ends: "Reported by @pawelsu8 with a source-level trace, stats() evidence from the affected Windows machine, and test-coverage requests that shaped the fix. Thank you."

Testing the fix turned up a second bug. What I wrote to him: "Testing it turned up a second problem that explains your 'can't open it from another process' symptom."

While writes were refused, the materializer kept retrying its pending work every 100ms, on every worker, forever. Each attempt took the store's write lock and was then aborted at commit. Seen from any other process, the file was write-locked almost continuously, so his attempt to open the store separately got "database is locked" even with a 30-second busy timeout. So would any attempt to repair it. That changes how the rest of the report reads. The refusal existed to protect the database until someone worked out what had gone wrong, and the engine's own retry loop was locking out every process that could have done the work. The materializer now s while writes are refused and picks back up once a check comes back clean.

Two new Python tests damage a store for real from a second process, by writing a row that violates a CHECK constraint (which quick_check reports), and assert that the engine recovers. One of them failed with exactly "database is locked" before the materializer change went in.

The engine now re-runs a failed quick_check once, refuses only if it fails again, and resumes by itself the next time a check passes. Someone could fairly argue that's too permissive. An integrity failure means possible corruption, and possible corruption should stop writes until a person has looked.

I think one retry plus auto-resume is the right setting for this engine. Its job is to be an agent's memory, and #247 is a detailed record of what a refusal that only a human can clear looks like in practice: hours of dropped writes, reads that make everything look fine, a remedy the user can't reach, and finally a workaround that switches the protection off permanently. The one person who hit this found "warn" by reading Rust source, and he's more careful than most users will be. A refusal that names the failure in the error and in stats(), and lifts when a check passes, is one people will leave turned on.

The cost is that the engine now treats a failure that doesn't repeat as a blip. If the storage underneath is bad enough to produce damage that only shows up some of the time, a clean check can resume writes on top of it. And the best evidence for the stricter view is still open on the issue.

Nobody knows why his process got a bad quick_check result in the first place. His offline copy of the same database checks clean. My last note on the issue says: "Still unknown: why your process got a bad quick_check result when your offline copy checks clean. The original result string was lost with the restart. If it happens again on 0.23.2, the error message and stats()['last_integrity_check'] will contain it, and integrity_checks_unconfirmed_since_boot will show whether it was a one-off. We'd be glad to see either."

I also asked him to switch the store from "warn" back to "refuse". Nothing needs the workaround on 0.23.2, and if it stays set, both refusal paths stay disarmed on every open of that file, including the next time a check fails for a reason none of us can explain yet.

Pranab Sarkar, Independent Researcher

── more in #ai-agents 4 stories · sorted by recency
── more on @yantrikdb 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-failed-sqlite-inte…] indexed:0 read:7min 2026-10-08 · —