Skip to content

fix(state): refuse SessionDB open and writes on a deleted WAL generation - #101081

Closed
astraltrekkin wants to merge 1 commit into
NousResearch:mainfrom
astraltrekkin:cursor/nousresearch-hermes-agent-101064-098b
Closed

astraltrekkin wants to merge 1 commit into
NousResearch:mainfrom
astraltrekkin:cursor/nousresearch-hermes-agent-101064-098b

Conversation

@astraltrekkin

@astraltrekkin astraltrekkin commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

A live writer can keep an open fd to an already-unlinked state.db-wal (or -shm) while a later SessionDB open would call sqlite3.connect and mint a fresh WAL at the same path. The two generations cannot see each other, which is the intermittent database disk image is malformed / disk I/O error field report.

This PR fails closed on that generation split:

  • Writable SessionDB.__init__ scans for deleted WAL/SHM holders (Linux /proc/*/fd, including this process) before sqlite3.connect, so a second opener cannot create the replacement WAL.
  • The write path records WAL/SHM (st_dev, st_ino) at open and halts if that identity is gone or this process still holds a (deleted) sidecar fd.
  • The same halt applies on reopen-after-close and FTS fail-open.
  • database.journal_mode stays wal by default. delete remains operator containment, not a new default.

The N live SessionDB handles warning and the repair-only deleted-holder work in #97330 are left alone.

Related Issue

Fixes #101064

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_state.py: add DeletedWalGenerationError, iter_deleted_sqlite_sidecar_holders(), and refuse_deleted_wal_generation(); call the refuse helper before writable connect; snapshot sidecar inodes in _record_db_file_identity; extend _raise_if_db_replaced / reopen / FTS fail-open to halt on a lost WAL generation; re-record identity after VACUUM TRUNCATE; classify the new error as "replaced" so in-file repair is not attempted.
  • tests/hermes_state/test_deleted_wal_generation_guard.py: open refuse (no new WAL inode), writer halt after unlink, DELETE-mode two writers still work, clean reopen still works.

How to Test

  1. On Linux, with a writable SessionDB forced into WAL, create a session, unlink state.db-wal / state.db-shm while the first handle is still open, then construct a second SessionDB on the same path. Expect DeletedWalGenerationError and no new WAL file at that path.
  2. On the same first handle, append another message after the unlink. Expect DeletedWalGenerationError and _db_wal_generation_lost is True on the next write as well.
  3. Repeat with resolve_journal_mode() returning delete. Two writers on the same path still append; no WAL sidecar appears.
scripts/run_tests.sh tests/hermes_state/test_deleted_wal_generation_guard.py tests/hermes_state/test_state_db_file_identity.py -q

18 passed (8 new + 10 existing identity tests).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Linux (OpenCloudOS-class kernel / Ubuntu userspace)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

After the fix, a second SessionDB open while the first handle still holds state.db-wal (deleted) raises:

DeletedWalGenerationError: FATAL: a live process holds a deleted state.db-wal or state.db-shm inode while the path names a different (or missing) generation. Refusing to open or write so a second WAL cannot be minted.

The path does not grow a replacement WAL. The original writer’s next append raises the same error instead of committing on the orphan inode.

A live writer can keep a deleted state.db-wal inode while a second opener
mints a fresh WAL at the same path. Fail closed on writable open (before
connect) and on the write-path sidecar identity check so the second
generation is never created.

Co-authored-by: Noa <rainbowgore@users.noreply.github.com>
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Sep 2, 2026
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference; please use your judgment.

PR #101081 — fix(state): refuse SessionDB open and writes on a deleted WAL generation

  • hermes_state.py:7,27-36,60-143 introduces DeletedWalGenerationError (classified as classify_persistence_error → "replaced" alongside StateDbReplacedError), _stat_sqlite_sidecar_identity, _canonical_sqlite_path (strips Linux (deleted) suffix), _watched_sqlite_sidecar_paths, iter_deleted_sqlite_sidecar_holders(db_path, include_self) (Linux-only /proc/<pid>/fd readlink scan, Windows/_IS_WINDOWS returns [] because Windows cannot unlink an open sidecar), and refuse_deleted_wal_generation() which raises before any sqlite3.connect so a second opener cannot mint a replacement WAL inode while a live writer still holds the orphan.
  • SessionDB.__init__:152-153,161-164,172 tracks _db_sidecar_identity and _db_wal_generation_lost, snapshots WAL/SHM inodes at open (_record_db_file_identity:189), and refuses on both the outer preflight and inside _connect_and_init. _wal_generation_was_lost() cheaply re-stats sidecar inodes vs recorded identity and scans /proc/self/fd for a (deleted) WAL/SHM fd; _halt_deleted_wal_generation() sets the lost flag. Guards added in _reopen_after_close_locked:181, _raise_if_db_replaced:242-247, _enter_fts_fail_open:292, and vacuum:304 re-adopts post-VACUUM sidecars via TRUNCATE checkpoint. _foreign_state_db_holders now canonicalizes via _canonical_sqlite_path.
  • tests/hermes_state/test_deleted_wal_generation_guard.py:313-510 (197 LOC, 7 tests) pins: classification → replaced not disk, non-Linux empty, clean open + second open, journal_mode=delete two-writers still works, /proc/self/fd holder detection after unlink, second SessionDB open refuses and never mints a new WAL inode, writer halts on append_message after own WAL unlinked and stays halted, and low-level refuse_deleted_wal_generation helper.

Non-blocking:

  • The /proc walk is Linux-only and debug-logs failures; macOS/_IS_WINDOWS paths are explicitly no-ops — correct platform contract per docstring.
  • VACUUM re-records identity so healthy exclusive maintenance does not trip the write-path guard — important for schema repair pathways.

Verdict: LGTM. Fail-closed split-brain fix for the intermittent malformed/disk I/O field reports, with both open-time (foreign holder scan) and write-time (inode + self-fd) signals and well-isolated tests.

kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 2, 2026
…r the deleted-WAL guard

Follow-ups on the salvaged NousResearch#101081 guard:

- A clean close() lets SQLite unlink the WAL sidecars legitimately; the
  guard treated that as a lost generation and permanently halted the
  handle, so the NousResearch#94736 late-write self-heal reopen dropped transcript
  tails (4 existing tests failed). close() now clears the recorded
  sidecar generation, and _wal_generation_was_lost() re-adopts the
  current sidecars after a clean /proc/self probe instead of relying on
  a stale snapshot.
- Healthy writes no longer walk /proc/self/fd: once a sidecar
  generation is recorded, the stat-based inode check alone detects an
  unlink/replace. The fd probe only runs in the empty-identity state
  (fresh DB, post-close reopen).
- DeletedWalGenerationError now subclasses StateDbReplacedError, so the
  gateway retry queue and run_agent flush divert transcripts to the
  JSONL fallback exactly as they do for a replaced store, instead of
  retrying forever against a halted handle.
- __init__ refuses once (under the startup lock) instead of twice per
  open, halving the system-wide /proc scan; dropped the dead
  include_self parameter and the dead _IS_WINDOWS clause.
- Test fixes: rstrip(' (deleted)') char-set bug -> removesuffix; the
  non-linux test now patches sys.platform (the real gate) instead of
  _IS_WINDOWS.
kshitijk4poor added a commit that referenced this pull request Sep 2, 2026
…d-WAL guard

Follow-ups on the salvaged #101081 guard:

- A clean close() lets SQLite unlink the WAL sidecars legitimately; the
  guard treated that as a lost generation and permanently halted the
  handle, so the #94736 late-write self-heal reopen dropped transcript
  tails (4 existing tests failed). close() now clears the recorded
  sidecar generation, and _wal_generation_was_lost() re-adopts the
  current sidecars after a clean /proc/self probe instead of relying on
  a stale snapshot.
- Healthy writes no longer walk /proc/self/fd: once a sidecar
  generation is recorded, the stat-based inode check alone detects an
  unlink/replace. The fd probe only runs in the empty-identity state
  (fresh DB, post-close reopen).
- DeletedWalGenerationError now subclasses StateDbReplacedError, so the
  gateway retry queue and run_agent flush divert transcripts to the
  JSONL fallback exactly as they do for a replaced store, instead of
  retrying forever against a halted handle.
- __init__ refuses once (under the startup lock) instead of twice per
  open, halving the system-wide /proc scan; dropped the dead
  include_self parameter and the dead _IS_WINDOWS clause.
- Test fixes: rstrip(' (deleted)') char-set bug -> removesuffix; the
  non-linux test now patches sys.platform (the real gate) instead of
  _IS_WINDOWS.
@kshitijk4poor

Copy link
Copy Markdown
Contributor

Merged via #101221 — your commit was cherry-picked with authorship preserved (7f7df1ce44 on main, authored by you). Thank you for the excellent detection design: refuse-before-connect plus sidecar inode identity is exactly the right shape for the #101064 split-WAL class, and keeping journal_mode: delete as operator containment rather than a new default was the right call.

Two adjustments were folded on top in the salvage (second commit e9fa7bc05e):

  • A clean close() legitimately lets SQLite unlink the WAL, which the guard read as a lost generation — that permanently halted the handle and regressed the Subagent/cron sessions silently die: 'Session DB append_message failed: NoneType object has no attribute execute' #94736 teardown-race self-heal (4 existing tests). close() now clears the recorded sidecar generation and the probe re-adopts current sidecars after a clean check.
  • DeletedWalGenerationError now subclasses StateDbReplacedError, so the gateway/run_agent transcript-divert paths handle the halt instead of retrying forever; the per-write /proc/self/fd walk was also dropped for recorded-generation and delete-mode handles (stat-only on the hot path).

Closing this PR in favor of the merged salvage. Thanks again!

nftpoetrist added a commit to nftpoetrist/hermes-agent that referenced this pull request Sep 3, 2026
…lted states

_get_read_conn()'s fresh-open guard (this branch's first commit) only
checked self._db_corrupt. Since that commit's base, hermes_state.py grew
two more halted states that _reopen_after_close_locked already refuses to
reopen for on the write side: self._db_replaced (the path now resolves to
a different file generation, NousResearch#89332) and self._db_wal_generation_lost
(this handle's WAL/SHM generation was deleted out from under it, salvage
of NousResearch#101081/NousResearch#101221). _get_read_conn() was unaware of both — it would
still happily open a brand-new sqlite3 connection to the file in either
state, the same "reopen a damaged/replaced image" hazard the first commit
fixed for the corrupt case.

Fixed by returning None (falling back to the locked path, same as the
_db_corrupt check) when either flag is set. Deliberately checks only the
already-set flags, not the proactive _db_file_was_replaced()/
_wal_generation_was_lost() detection probes _reopen_after_close_locked
also runs — that keeps this hot-path check as cheap as the existing
_db_corrupt check (a bare flag test), consistent with this function's
established style. An out-of-band replace/WAL-loss not yet observed by
this handle is still caught the next time a write or an explicit check
touches it.

Added two tests mirroring the existing
test_get_read_conn_refuses_fresh_open_when_quarantined:
test_get_read_conn_refuses_fresh_open_when_replaced and
test_get_read_conn_refuses_fresh_open_when_wal_generation_lost, both
forcing _wal_active=True on a live (not yet closed) handle and asserting
_get_read_conn() returns None without ever calling the mocked
_connect_tracked_db.

Mutation-verified: reverting the hermes_state.py hunk alone makes both
new tests fail with the mock connection object returned instead of None;
restoring it makes them pass. Ran tests/hermes_state/ (11 passed, 1
pre-existing skip) and tests/state/ + tests/hermes_state/ together (417
passed, 7 skipped, all pre-existing) — no regressions.

Fresh competitor check: no open or closed PR covers this specific gap.
NousResearch#101042 ("set busy_timeout=5000 on writer and reader connections") also
touches _get_read_conn() but only inside the try block after the
connection is already opened (adds a busy_timeout PRAGMA) — no line
overlap with this commit's checks near the top of the function.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…r the deleted-WAL guard

Follow-ups on the salvaged NousResearch#101081 guard:

- A clean close() lets SQLite unlink the WAL sidecars legitimately; the
  guard treated that as a lost generation and permanently halted the
  handle, so the NousResearch#94736 late-write self-heal reopen dropped transcript
  tails (4 existing tests failed). close() now clears the recorded
  sidecar generation, and _wal_generation_was_lost() re-adopts the
  current sidecars after a clean /proc/self probe instead of relying on
  a stale snapshot.
- Healthy writes no longer walk /proc/self/fd: once a sidecar
  generation is recorded, the stat-based inode check alone detects an
  unlink/replace. The fd probe only runs in the empty-identity state
  (fresh DB, post-close reopen).
- DeletedWalGenerationError now subclasses StateDbReplacedError, so the
  gateway retry queue and run_agent flush divert transcripts to the
  JSONL fallback exactly as they do for a replaced store, instead of
  retrying forever against a halted handle.
- __init__ refuses once (under the startup lock) instead of twice per
  open, halving the system-wide /proc scan; dropped the dead
  include_self parameter and the dead _IS_WINDOWS clause.
- Test fixes: rstrip(' (deleted)') char-set bug -> removesuffix; the
  non-linux test now patches sys.platform (the real gate) instead of
  _IS_WINDOWS.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

5 participants