NousResearch/hermes-agent

state.db WAL TRUNCATE checkpoint corruption and PASSIVE checkpoint changes

Cluster 44795 · agent. Data as of

2 open items. Fix merged in #84881 on 13 Aug 2026. Check it covers your case.

The short answer

0open issues
2open PRs
11items grouped
Known?
Yes. 11 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #84881. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #64607, #84881. That is separate from the fix event.
Still open
0 issues and 2 PRs.

Members center on state.db B-tree corruption reported in connection with WAL TRUNCATE checkpoints, especially on large FTS5 databases. Most are pull requests proposing PASSIVE checkpoints in periodic, close, and pre-VACUUM paths, while some also address logging or surfacing checkpoint failures that were silently swallowed.

Open issues0

No open issues.

Open PRs2

  • PR#44834fix(state): log WAL checkpoint failures instead of silently swallowing
  • PR#44835fix(state): surface failed WAL checkpoints instead of silently swallowing them

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #64607 fix(state): PASSIVE periodic WAL checkpoint to stop B-tree corruption (#45383)

    merged to main

    files: hermes_state.py, tests/test_wal_checkpoint_strategy.py

    Show the diff
    • hermes_state.py
      @@ -986,7 +986,7 @@ class SessionDB:
           _WRITE_MAX_RETRIES = 15
           _WRITE_RETRY_MIN_S = 0.020   # 20ms
           _WRITE_RETRY_MAX_S = 0.150   # 150ms
      -    # Attempt a PASSIVE WAL checkpoint every N successful writes.
      +    # Attempt a WAL checkpoint every N successful writes (PASSIVE mode).
           _CHECKPOINT_EVERY_N_WRITES = 50
           # Merge fragmented FTS5 segments every N successful writes. The message
           # triggers append one segment per insert; left unmaintained these grow
      @@ -1301,35 +1301,34 @@ def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
               )
       
           def _try_wal_checkpoint(self) -> None:
      -        """Best-effort TRUNCATE WAL checkpoint.  Never raises.
      -
      -        Flushes committed WAL frames back into the main DB file and
      -        truncates the WAL file to zero bytes.  Keeps the WAL from
      -        growing unbounded when many processes hold persistent
      -        connections.
      -
      -        PASSIVE checkpoint was previously used here, but it never
      -        truncates the WAL file — the file stays at its high-water
      -        mark until an explicit TRUNCATE is called (which only
      -        happened inside the infrequent vacuum()).
      -
      -        TRUNCATE may block writers briefly while checkpointing, but
      -        _try_wal_checkpoint is called off the hot path (every 50
      -        writes) and already runs under ``self._lock``, so the
      -        additional hold time is negligible.
      +        """Best-effort PASSIVE WAL checkpoint.  Never raises.
      +
      +        Flushes committed WAL frames back into the main DB file without
      +        requiring an exclusive lock.  PASSIVE is safe for frequent
      +        periodic use because it does not block concurrent writers and
      +        cannot corrupt B-tree pages under I/O pressure.
      +
      +        PASSIVE does not truncate the WAL file — it stays at its
      +        high-water mark.  WAL truncation happens in :meth:`close`
      +        (TRUNCATE) and pre-VACUUM checkpoints, which run infrequently
      +        under controlled conditions.
      +
      +        Previous TRUNCATE strategy caused B-tree corruption on large
      +        databases (65K+ pages) due to the exclusive-lock I/O pressure
      +        from checkpointing thousands of frames at once (issue #45383).
               """
               try:
                   with self._lock:
                       result = self._conn.execute(
      -                    "PRAGMA wal_checkpoint(TRUNCATE)"
      +                    "PRAGMA wal_checkpoint(PASSIVE)"
                       ).fetchone()
                       if result and result[1] > 0:
                           logger.debug(
                               "WAL checkpoint: %d/%d pages checkpointed",
                               result[2], result[1],
                           )
      -        except Exception:
      -            pass  # Best effort — never fatal.
      +        except Exception as exc:
      +            logger.warning("WAL checkpoint (PASSIVE) failed: %s", exc)
       
           def _try_optimize_fts(self) -> None:
               """Best-effort FTS5 segment merge. Never raises.
      @@ -1357,8 +1356,8 @@ def close(self):
                   if self._conn:
                       try:
                           self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
      -                except Exception:
      -                    pass
      +                except Exception as exc:
      +                    logger.debug("WAL checkpoint (TRUNCATE) at close failed: %s", exc)
                       self._conn.close()
                       self._conn = None
       
      @@ -7015,8 +7014,8 @@ def vacuum(self) -> int:
                   # Best-effort WAL checkpoint first, then VACUUM.
                   try:
                       self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
      -            except Exception:
      -                pass
      +            except Exception as exc:
      +                logger.debug("WAL checkpoint (TRUNCATE) before VACUUM failed: %s", exc)
                   self._conn.execute("VACUUM")
               return optimized
       
    • tests/test_wal_checkpoint_strategy.py
      @@ -0,0 +1,138 @@
      +"""Tests for SessionDB WAL checkpoint strategy (issue #45383).
      +
      +Verifies that periodic checkpoints use PASSIVE mode (safe for large DBs)
      +while close() and pre-VACUUM paths still use TRUNCATE.
      +"""
      +
      +import sqlite3
      +import logging
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +from hermes_state import SessionDB
      +
      +
      [redacted:email]()
      +def db(tmp_path):
      +    """Create a SessionDB with a temp database file."""
      +    db_path = tmp_path / "test_state.db"
      +    session_db = SessionDB(db_path=db_path)
      +    yield session_db
      +    try:
      +        session_db.close()
      +    except Exception:
      +        pass
      +
      +
      +class TestTryWalCheckpointPassive:
      +    """_try_wal_checkpoint() should use PASSIVE mode for periodic use."""
      +
      +    def test_checkpoint_uses_passive_mode(self, db):
      +        """PASSIVE checkpoint does not require exclusive lock — safe for large DBs."""
      +        # Capture the real connection's execute before mocking
      +        real_conn = db._conn
      +        execute_calls = []
      +
      +        def tracking_execute(sql, *args, **kwargs):
      +            execute_calls.append(sql)
      +            return real_conn.execute(sql, *args, **kwargs)
      +
      +        # sqlite3.Connection.execute is read-only (C extension) — replace _conn
      +        mock_conn = MagicMock()
      +        mock_conn.execute.side_effect = tracking_execute
      +        mock_conn.fetchone.return_value = None
      +        db._conn = mock_conn
      +
      +        db._try_wal_checkpoint()
      +
      +        passive_calls = [c for c in execute_calls if "wal_checkpoint(PASSIVE)" in c]
      +        truncate_calls = [c for c in execute_calls if "wal_checkpoint(TRUNCATE)" in c]
      +        assert len(passive_calls) == 1, (
      +            f"Expected 1 PASSIVE checkpoint call, got {len(passive_calls)}"
      +        )
      +        assert len(truncate_calls) == 0, (
      +            "Periodic checkpoint should NOT use TRUNCATE"
      +        )
      +
      +    def test_checkpoint_logs_warning_on_failure(self, db, caplog):
      +        """Failed PASSIVE checkpoint logs a warning instead of silent pass."""
      +        mock_conn = MagicMock()
      +        mock_conn.execute.side_effect = sqlite3.OperationalError("disk I/O error")
      +        db._conn = mock_conn
      +
      +        with caplog.at_level(logging.WARNING):
      +            db._try_wal_checkpoint()
      +
      +        assert any("WAL checkpoint (PASSIVE) failed" in r.message for r in caplog.records), (
      +            f"Expected warning log about PASSIVE checkpoint failure, got: {caplog.text}"
      +        )
      +
      +    def test_checkpoint_returns_result_on_success(self, db):
      +        """Successful PASSIVE checkpoint does not raise."""
      +        db._try_wal_checkpoint()
      +
      +
      +class TestCloseUsesTruncate:
      +    """close() should still use TRUNCATE to shrink WAL on shutdown."""
      +
      +    def test_close_uses_truncate_mode(self, db):
      +        """TRUNCATE at close is safe — no concurrent writers during shutdown."""
      +        real_conn = db._conn
      +        execute_calls = []
      +
      +        def tracking_execute(sql, *args, **kwargs):
      +            execute_calls.append(sql)
      +            return real_conn.execute(sql, *args, **kwargs)
      +
      +        mock_conn = MagicMock()
      +        mock_conn.execute.side_effect = tracking_execute
      +        db._conn = mock_conn
      +
      +        db.close()
      +
      +        truncate_calls = [c for c in execute_calls if "wal_checkpoint(TRUNCATE)" in c]
      +        assert len(truncate_calls) == 1, (
      +            f"Expected 1 TRUNCATE checkpoint at close, got {len(truncate_calls)}"
      +        )
      +
      +    def test_close_logs_debug_on_failure(self, db, caplog):
      +        """Failed TRUNCATE at close logs debug (not warning — close is best-effort)."""
      +        mock_conn = MagicMock()
      +        mock_conn.execute.side_effect = sqlite3.OperationalError("database is locked")
      +        db._conn = mock_conn
      +
      +        with caplog.at_level(logging.DEBUG):
      +            db.close()
      +
      +        assert any("WAL checkpoint (TRUNCATE) at close failed" in r.message for r in caplog.records), (
      +            f"Expected debug log about TRUNCATE failure at close, got: {caplog.text}"
      +        )
      +
      +
      +class TestCheckpointFrequency:
      +    """Checkpoint triggers every N writes."""
      +
      +    def test_checkpoint_triggers_at_interval(self, db):
      +        """_try_wal_checkpoint is called every _CHECKPOINT_EVERY_N_WRITES writes."""
      +        call_count = [0]
      +        original = db._try_wal_checkpoint
      +
      +        def counting_checkpoint():
      +            call_count[0] += 1
      +            original()
      +
      +        db._try_wal_checkpoint = counting_checkpoint
      +
      +        # Write exactly _CHECKPOINT_EVERY_N_WRITES sessions to trigger one checkpoint
      +        n = db._CHECKPOINT_EVERY_N_WRITES
      +        import time as _time
      +        for i in range(n):
      +            db._execute_write(lambda conn, _i=i: conn.execute(
      +                "INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
      +                (f"sess_{_i}", "test", _time.time()),
      +            ))
      +
      +        assert call_count[0] == 1, (
      +            f"Expected 1 checkpoint after {n} writes, got {call_count[0]}"
      +        )
  • #84881 fix(state): PASSIVE not TRUNCATE for all shared-DB WAL checkpoints

    merged to main

    files: contributors/emails/[redacted:email], hermes_cli/kanban_db.py, hermes_state.py, hermes_state_search.py, tests/hermes_cli/test_kanban_db_repair.py, tests/test_hermes_state.py, tests/test_wal_checkpoint_strategy.py

5 PRs closed without merging

From the close comments: done elsewhere 2 · closed without comment 1 · other 2

  • #44917 fix(state): use PASSIVE WAL checkpoint to prevent corruption (#44795) · other
  • #45396 fix(state): use PASSIVE checkpoint for periodic WAL flush to prevent B-tree corruption · other → 46b2afc, 9aba95b, #45383, #45546
  • #45546 fix: prevent state.db B-tree corruption from WAL TRUNCATE checkpoint · done elsewhere → #45383
  • #72546 fix(state): use PASSIVE WAL checkpoint in close() and pre-VACUUM paths (#45383) · closed without comment
  • #72549 fix(state): use PASSIVE WAL checkpoint in close() and pre-VACUUM paths (#45383) · done elsewhere → 7965462
Closed issues · 2
  • Closed#44795[Bug]: _try_wal_checkpoint TRUNCATE silently swallows exceptions, corrupts state.db WAL to zero bytes
  • Closed#45383state.db recurring B-tree corruption from WAL TRUNCATE checkpoint on large FTS5 databases
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#64607, #84881
Closed issues
2
Attempts
5 PRs closed without merging
Fix link checked
13 Aug 2026

For your agent

The same facts, typed.