Conversation
…generation A gateway SIGTERM closes SessionDB cleanly, which checkpoints and unlinks the state.db-wal/-shm sidecars. Surviving kanban workers (separate processes) still hold the retired WAL generation, so their next append raises sqlite3.OperationalError: unable to open database file (SQLITE_CANTOPEN) instead of Hermes's prose guard. classify_persistence_error had no marker for that raw string, so the turn was abandoned as session_persistence_failed (unknown) instead of routing to the existing deleted_wal recovery path.
A surviving writer whose -wal/-shm sidecars a clean gateway close unlinked
raises sqlite3.OperationalError("unable to open database file") on its next
append, not the prose DeletedWalGenerationError guard (which only fires on the
open path and on a write whose recorded sidecar identity changed). That raw
CANTOPEN matched neither the StateDbReplaced/Corrupt divert nor the compression
retry, so _db_flush_failed returned False with the batch lost — the turn died
AND its writes vanished.
Level 1 (data safety): divert the batch to sessions/<id>.jsonl whenever the
cause classifies as deleted_wal, exactly like the replaced/corrupt cases. The
flush still fails closed (False -> session_persistence_failed); no reopen or
replay is added, so a genuinely invalid path can never silently mint a database.
Test fails on main (no divert -> FileNotFoundError on the jsonl), passes with
the fix.
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head fed9c1dc81afc2e912c9718c79dc3fd6481ca6c7 against current main f5d192611032025d2757b07ad838921872126182 (2 commits ahead, 0 behind, currently mergeable).
The level-1 direction is right: preserve the failed turn batch somewhere durable, keep the turn failed, and do not auto-reopen/replay a writer whose generation cannot be proven safe. That is the correct failure posture for the real retired-WAL class.
I do have one P1 data-integrity blocker before this classification can land, because the new predicate turns an indirect SQLite symptom into a specific generation proof and then exposes that proof through user-facing recovery/settlement copy.
P1 — SQLITE_CANTOPEN is not proof of a deleted WAL generation
"unable to open database file" is SQLite's generic SQLITE_CANTOPEN text. The repository already has a concrete counterexample in #29610 / wyjuven: the exact same sqlite3.OperationalError: unable to open database file occurs after descriptor exhaustion ([Errno 24] Too many open files) from leaked Kanban SQLite handles. A missing/uncreatable parent directory produces the same SQLite error as well. Neither condition establishes that state.db-wal / state.db-shm was retired.
The consequence is stronger than a diagnostic mislabel. With this head:
classify_persistence_error()turns any matching CANTOPEN intodeleted_wal._db_flush_failed()therefore attempts the JSONL divert.- If CANTOPEN came from FD exhaustion, the divert itself can fail opening
sessions/<id>.jsonl; that exception is swallowed after a warning. _last_persistence_error_causenevertheless remainsdeleted_wal, and the existing turn explainer for that cause says "a copy is kept in …/sessions/".
That can produce a false durability receipt: the user is told the unsaved message was preserved when the fallback write never landed. This is the same architectural shape that the WAL campaign has already been eliminating: an indirect signal is accepted as authority for a stronger state claim.
The fix does not require giving up the level-1 behavior. Keep generic CANTOPEN generic (or give it its own cause) unless the failing boundary has direct sidecar-generation evidence: DeletedWalGenerationError, an identity/holder check, or a provenance-bearing wrapper raised after that check. If the desired policy is “attempt a transcript divert on every SQLITE_CANTOPEN,” make that decision independently at _db_flush_failed() using the actual SQLite error code; do not widen the deleted_wal classification to get there. And bind any “copy kept” claim to the actual diversion receipt/path, not merely to the error bucket.
The open #110179 / ngpestelos is especially relevant here and should be composed rather than duplicated: it already changes this same persistence seam to retain _last_diverted_transcript_path from divert_session_transcript_jsonl() (or None on failure) and adds replay/inspection of that exact artifact. Preserve that authorship and settle merge order if both carriers proceed.
Interlocks / provenance
- #105670 / maurohdev is genuine deleted-WAL-generation evidence and the predecessor containment/capture lineage should remain the semantic source for that bucket.
- Merged #108082 / teknium1 is the most important architectural precedent: it closed several state/WAL false positives whose shared root was trusting a single indirect signal as proof of a permanent condition. This head should not reintroduce that class through generic CANTOPEN prose.
- #110179 / ngpestelos is complementary and directly file-overlapping (
agent/session_persistence.py,agent/turn_explainers.py): it owns truthful diverted-artifact path/replay work, not this CANTOPEN classification. - #109766 / Sahilvishnaliya + #109687 are adjacent self-heal work for a proven lost generation, not duplicates of this level-1 preservation carrier.
- #29610 / wyjuven is the concrete hostile witness showing this exact CANTOPEN text has another reachable cause inside Hermes.
Acceptance
Hosted acceptance is currently absent on both surviving commits. Parent 2db7bc18a82f3e912ec5a6b0f30e84ec701c60a6 has CI 35201160136, Docker 35201159651, and Nix 35201159653, all action_required. Exact head has CI 35206547413, Docker 35206547071, and Nix 35206547094, also all action_required; the exact-head CI run created zero jobs. So this is 0/2 surviving commits green.
Required closure: narrow the cause classification to evidence that actually proves the WAL generation state; keep the level-1 divert independent if desired; make the “copy kept” settlement conditional on a successful durable divert; add negative controls for non-WAL CANTOPEN plus divert failure; compose the overlapping receipt/replay work with #110179 without erasing credit; then establish exact-head and every-surviving-commit green acceptance.
The core fail-closed instinct here is good. The blocker is specifically that the proof and the durability claim are currently wider than the evidence.
| (("deleted state.db-wal", "deleted state.db-shm"), "deleted_wal"), | ||
| # "unable to open database file" (SQLITE_CANTOPEN) is the raw sqlite3 error a surviving | ||
| # writer sees after a clean close unlinked the -wal/-shm sidecars it still holds. | ||
| (("deleted state.db-wal", "deleted state.db-shm", "unable to open database file"), "deleted_wal"), |
There was a problem hiding this comment.
[P1] Don't promote generic SQLITE_CANTOPEN prose into deleted-WAL proof. "unable to open database file" is not generation-specific: #29610 records this exact SQLite error under FD exhaustion, and an invalid/missing parent path produces it too. Because this global classifier feeds _db_flush_failed() and the user-facing deleted_wal explainer, a non-WAL CANTOPEN can be mislabeled as generation loss; if the JSONL divert also fails (very plausible under EMFILE), Hermes still retains deleted_wal and can tell the user a copy was kept when no durable fallback exists. Keep this bucket tied to direct generation evidence (DeletedWalGenerationError / identity-holder proof). If you want level-1 diversion for every CANTOPEN, detect SQLITE_CANTOPEN at the flush boundary independently and bind the preservation claim to the actual divert receipt/path (the complementary #110179 already carries that receipt shape).
|
Checked against a clean The flush half holds up. Your two tests, copied onto an unmodified Where I would push back is the phrase mapping.
Rows 1 and 2 are storage that is unreachable or unwritable, nothing was retired, and the user-facing copy for this bucket ( I also could not reproduce the stated mechanism on POSIX. With a live WAL connection, unlinking The divert itself is worth keeping for any open failure; it is the cause bucket I would scope more narrowly. Reporting the measurements rather than asking for a rewrite. |
…neration Review follow-up on NousResearch#113999. `unable to open database file` is SQLITE_CANTOPEN — generic ("could not open it": missing parent dir, unreadable file, FD exhaustion) — so classifying it as `deleted_wal` asserted a generation proof the error does not carry, and fed the "a copy is kept in sessions/" copy (agent/turn_explainers.py) to users whose divert write can itself fail. It now classifies as `disk`, placed AFTER `locked` so a busy that merely mentions the open failure stays retryable. The proven retired-generation case keeps its own type and its sidecar-identity / proc-fd evidence. The divert decision moves to where the batch is actually lost: `_db_flush_failed` diverts when the cause is `deleted_wal` or `disk`, so a raw CANTOPEN still keeps its batch on disk — without widening a classification bucket upstream. No reopen/replay is added: fail-closed is unchanged. Tests (tests/agent/test_flush_diverts_on_cantopen.py, tests/hermes_state/test_deleted_wal_generation_guard.py) fail on the base f5d1926 and pass on this head, including two negative controls: a locked+open failure stays `locked`, and a failing divert stays quiet and fail-closed and fabricates nothing.
|
Addressed in What changed
On the stated mechanism I could not reproduce it either, on Linux (Python 3.13.13 / SQLite 3.51.2). A clean close while another connection is live does not unlink What is unchanged: Not in this PR The "a copy is kept in Evidence
|
andrexibiza
left a comment
There was a problem hiding this comment.
Re-review — 197f71f47b96225afd30e97e68298915ba2652ba
The CANTOPEN classification P1 from my previous review is resolved on this head. The correction separates the storage symptom from a claim about WAL-generation identity, while retaining the failed-batch diversion. This addresses the classification counterexamples in strzhao’s measurements, without adding speculative recovery.
I found no new runtime blocker in the four-file diff. There is one P2 test-contract correction, inline below. This is not yet a merge-ready acceptance result: the PR remains draft and 0/3 surviving commits have green CI/Docker/Nix acceptance.
What is now correct
- Cause precedence is preserved. In the classifier, typed lease/compression/generation errors and FTS-scoped corruption retain their existing precedence. Generic CANTOPEN becomes
disk; the combinedunable to open database file: database is lockedcontrol remainslocked. A failed open is no longer promoted into evidence of a retired WAL. - The new behavior belongs to the failure boundary.
_db_flush_failedattempts diversion without reporting SQLite success. The surrounding flush still returnsFalse, and the success-only message-marker stamping remains afterappend_messages_batch. The patch does not add reconnect, replay, or a new retry path; the existing typed compression-continuation retry is separate. - The introduced false-copy claim is removed for CANTOPEN. The
diskexplainer does not say “a copy is kept.” A failed CANTOPEN diversion therefore no longer reaches the unconditionaldeleted_walcopy claim that made the prior classification dangerous. The remaining unconditional wording forreplaced/deleted_walis pre-existing; it should not be represented as a new defect introduced by this revision.
Verification and its limits
I read all four changed files, the complete flush implementation, the actual JSONL writer, the cause-to-user-copy mapping, the existing discussion, and the current-main versions of the two production seams.
I also ran an isolated, 25-case source-excerpt harness against the selected classifier and failure/diversion definitions: 25 passed, 0 failed; exit 0 on Python 3.13.5 / SQLite 3.46.1. Imports and the temporary home were wired by the harness; SQLite error generation and JSONL filesystem reads/writes were real. Controls included native missing-parent SQLITE_CANTOPEN, native read-only rejection, native SQLITE_BUSY, injected ENOSPC/FULL/IOERR, typed generation/lease/compression precedence, FTS precedence, multi-row JSON round-trip, and diversion failures with a directory at the artifact path or a file at the sessions-directory path.
Those are boundary checks, not execution of the repository’s AIAgent/SessionDB suites or its public turn loop. The reported 20 passed / 7 skipped remains the author’s repository-suite receipt, not a result I independently reproduced. No packaged, gateway, or end-to-end acceptance is claimed here.
P2 — make the no-reopen test observe the prohibited effect
test_raw_cantopen_fails_closed_without_reopen currently checks only False and one occurrence of the saved payload. An implementation that reopens/retries, fails again, and then diverts once would still satisfy both assertions. The current production implementation does not do that, but the test does not guard the contract its name and comment claim.
After fixture setup, forbid or spy on connection creation/reopening and premature close, count append attempts, and assert the original DB/connection identity survives the flush. Keep the existing failure/result and parsed JSONL assertions. Also parameterize the flush test over the other newly admitted disk causes: this predicate now covers FULL, read-only, and IOERR, not only CANTOPEN; keep locked, FTS, and lease failures as negative controls.
Interlock and current-main integration
#110179, by ngpestelos, is still open and unmerged, at 4696d9d48131d842199db16ae87bb101eaabeaa1. Its actual persistence patch captures the diversion return path and clears it on exception. It is complementary, not a replacement for this PR. Keep that receipt/replay work there rather than duplicating it here. When the two are composed, retain both this broadened predicate and the receipt assignment/clear inside it; restoring either whole older handler would lose the other behavior. This narrow CANTOPEN correction need not absorb that larger PR to resolve the original classification P1.
The reviewed merge base is f5d192611032025d2757b07ad838921872126182. The separately fetched live main was 5dd70d7cb6560c3ff8aff294ec44ec4f8d1558e5: 3 ahead / 3,208 behind. Current main still lacks this CANTOPEN change, so it is not already superseded there. Its surrounding persistence code has moved, including notification-row handling and checkpoint cleanup; preserve those changes when bringing this branch current. The reported mergeable flag is not a substitute for testing that combined tree.
Hosted acceptance remains outstanding
Fresh reads return action_required for every workflow linked below, not passing checks. The exact-head CI run has zero jobs.
| Surviving commit | CI | Docker | Nix |
|---|---|---|---|
2db7bc18a82f |
35201160136 | 35201159651 | 35201159653 |
fed9c1dc81af |
35206547413 | 35206547071 | 35206547094 |
197f71f47b96 |
35226252642 | 35226252320 | 35226252315 |
Before acceptance, establish green results for the surviving commits and the actual integrated head, including the touched flush/WAL suites and adjacent turn-explainer/file-identity coverage. Also narrow the PR-body guarantee from “the batch is never silently lost” to best-effort preservation when the JSONL write succeeds: the negative control deliberately demonstrates that the fallback itself can fail.
Bottom line: the earlier P1 is closed by this revision; retain fail-closed, no-auto-reopen behavior, strengthen the no-reopen regression, and complete hosted/integration acceptance without duplicating the separate recovery carrier.
|
|
||
| assert result is False # visible failure, never a silent success | ||
| # No retry/reopen path was taken: the batch is diverted exactly once and the handle is left alone. | ||
| jsonl = tmp_path / "sessions" / "live.jsonl" | ||
| assert jsonl.read_text(encoding="utf-8").count("still-saved") == 1 |
There was a problem hiding this comment.
[P2] Observe reopening/retry, not just the final JSONL count. These assertions establish False plus one saved payload, but would also pass if a future implementation closed/reopened the handle, retried the always-failing append_messages_batch, and only then diverted once. Nothing here observes connection creation, close, append-attempt count, or handle identity, so the comment’s “No retry/reopen path was taken” is not established. After creating the fixture DB, forbid/spy on reconnect and premature close, assert exactly one append attempt, and assert the original DB/connection remains attached. Keep the failure and JSONL checks. The current implementation has no added reopen path; this is a gap in the regression guard for that explicit contract.
What this fixes
A raw
SQLITE_CANTOPEN(sqlite3.OperationalError: unable to open database file) on the flush path lost the turn's batch silently._db_flush_failed(agent/session_persistence.py) only diverted to JSONL onisinstance(e, (StateDbReplacedError, StateDbCorruptError)); a raw CANTOPEN matches neither, so the batch was neither written to SQLite nor kept on disk — the turn's messages vanished.It now diverts whenever the classified cause is
deleted_walordisk, and the cause carries an honest storage label instead of a generation claim.Fail-closed is unchanged: the flush still returns
False→session_persistence_failed; no reopen/replay is added.Review follow-up (
197f71f47)Two reviews (P1 from @andrexibiza, measurements from @strzhao) converged on the same point, and I agree with it: the divert belongs at the flush site, and mapping generic
SQLITE_CANTOPENtodeleted_walclaimed a proof the error does not carry.deleted_wal. It classifies asdisk, placed after thelocked/busybucket, sounable to open database file: database is lockedstays retryable, while a missing parent directory or an unreadable file readsdiskrather thanunknownordeleted_wal. The proven retired-generation case keeps its own type (DeletedWalGenerationError) and its sidecar-identity / proc-fd evidence._db_flush_failed(), on the classified cause — not by widening a bucket upstream.-wal/-shm(SQLite unlinks only at the last connection's close), and unlinking both sidecars under a live handle still allows insert, commit,wal_checkpoint(TRUNCATE)and a fresh connect. Every CANTOPEN I could induce came from a missing directory or an unreadable file — the same result @strzhao got on darwin. This PR therefore no longer asserts the retired-generation mechanism: it preserves the batch on the raw symptom, whatever produces it.Session DB append_message failed: unable to open database file→reason=session_persistence_failed. That is what the divert now covers.deleted_wal/replaced/corruptcopy still says "a copy is kept in{home}/sessions/" although the divert's own exception is swallowed. That gap is pre-existing and fix: refuse held state.db publish; replay diverted transcripts #110179 (_last_diverted_transcript_path) owns it; duplicating it here would conflict with that carrier. Say the word if you would rather have it folded in.Changes
agent/session_persistence.py—_db_flush_faileddiverts the batch tosessions/<id>.jsonlwhen the cause isdeleted_walordisk, exactly like the replaced/corrupt cases.hermes_state_errors.py—"unable to open database file"maps todisk, afterlocked.Tests
tests/agent/test_flush_diverts_on_cantopen.py— a raw CANTOPEN diverts the batch to JSONL and fails closed; a negative control asserts that a failing divert (unwritablesessions/path) stays quiet, fail-closed, and fabricates nothing; a third test pins the no-reopen contract.tests/hermes_state/test_deleted_wal_generation_guard.py— generic CANTOPEN classifies asdisk, with the locked negative control stayinglocked.Measured against the base
f5d192611: 4 failed with these tests. On this head: 20 passed, 7 skipped.Verdict: level 1 only — level 2 (reopen + replay) deliberately declined
The follow-up asked to detect the lost WAL generation and, if cleanly checkpointed, reopen the handle and replay the append once. I declined that level after tracing the recovery machinery, for three reasons:
refuse_deleted_wal_generation/_halt_if_db_generation_changedexist to prevent minting a second WAL over a deleted generation (split-brain → intermittentSQLITE_CORRUPT/IOERR, WAL generation split-brain: DeletedWalGenerationError guarded writes, but the graceful-shutdown checkpoint then corrupted state.db (field report) #105670). A turn-level reopen reintroduces that failure mode.-walwith uncommitted frames" (committed data loss). Reopening in the latter case would silently drop data — the exact silent failure the recovery was meant to avoid.hermes sessions recover --source <dir>/state.db, withcapture_retired_wal_generationpreserving the retired frames for the operator to decide whether they belong on the main file. A turn-level auto-reopen would duplicate this, more riskily, without that decision.Net effect: the batch is never silently lost (level 1, non-negotiable); the turn still aborts rather than risk a corrupt recovery. Whatever emits the raw CANTOPEN (worker drain ordering vs the gateway's clean close) is tracked separately and is not addressed here.
Out of scope