Skip to content

fix: prevent silent message loss when state.db persistence fails - #32805

Closed
mlinquan wants to merge 12 commits into
NousResearch:mainfrom
mlinquan:fix/session-db-fallthrough
Closed

fix: prevent silent message loss when state.db persistence fails#32805
mlinquan wants to merge 12 commits into
NousResearch:mainfrom
mlinquan:fix/session-db-fallthrough

Conversation

@mlinquan

@mlinquan mlinquan commented May 26, 2026

Copy link
Copy Markdown

Problem

When append_message() fails (WAL lock contention, disk full, SQLITE_READONLY, etc.), messages accumulate in messages[] and _last_flushed_db_idx is never advanced. On agent eviction, context compression, or process restart, those unflushed messages are silently lost. The Gateway also blindly trusts agent_persisted — it assumes DB writes succeeded as long as _session_db is not None, never verifying the agent actually committed anything.

Additionally, SQLITE_READONLY (not just LOCKED/BUSY) was not retried in _execute_write, causing transient WAL-readonly states to permanently fail. And background review agents ran without a session_db handle, so their writes went nowhere when the session DB had been recovered from a previous failure.

Changes (12 commits, rebased on main)

Pending Fallback — Direct, No Sticky Flag

run_agent.py_flush_messages_to_session_db
Per-message try/except. Each successful append_message advances _last_flushed_db_idx. On failure, immediately calls _write_pending_fallback(messages, last_successful_idx) to dump unflushed messages to sessions/<sid>.pending.jsonl — no sticky flag, no deferred write.

run_agent.py_persist_session
Added a catch: when _session_db is None (SQLite init never succeeded), writes ALL messages to pending fallback via _write_pending_fallback(messages, 0).

run_agent.py_write_pending_fallback
New method. Wraps each message with its _flush_timestamp as {"_fallback_timestamp": t, "message": m} and appends to <sid>.pending.jsonl, respecting HERMES_PROFILE isolation.

Shared Flush Timestamp for Safe Dedup

Every append_message in _flush_messages_to_session_db now generates a timestamp ONCE and passes it to both the DB write (via the new timestamp= param of SessionDB.append_message) and the message dict as _flush_timestamp. This anchors the dedup comparison: if a DB write succeeded but the fallback was still written (false alarm), both carry the same timestamp and won't be double-inserted during recovery.

Deduplication is based on (role, content, tool_name, tool_calls, tool_call_id, timestamp) — max-overlap suffix-vs-prefix match between existing DB messages and pending entries.

SQLITE_READONLY Retry

hermes_state.py_execute_write
Added "readonly" and "read-only" to the retry-eligible error string set. Previously only "locked" and "busy" were retried — WAL-mode readonly states were fatal on first attempt.

Background Review Agent Gets session_db

agent/background_review.py
Pass session_db=getattr(agent, "_session_db", None) to review agents so their writes land in the correct session store.

Gateway Startup Recovery

gateway/run.py_recover_pending_messages
New function, called in GatewayRunner.__init__. Scans <hermes_home>/sessions/*.pending.jsonl, reads wrapped + plain messages, deduplicates against existing DB rows, replays new ones via SessionDB.append_message (with original timestamps, multimodal handling, reasoning fields), and deletes the pending file on success.

Hardening & Cleanup

  • Log append_message failure reason before triggering pending fallback (was silently swallowed)
  • Clear _session_db_create_fail_count on successful recovery so consecutive-failure logging starts fresh
  • Restore accidentally removed comments in _flush_messages_to_session_db that documented the cursor-tracking pattern
  • Delete pending files after recovery instead of renaming (simpler, safer)
  • Add _session_db_create_fail_count init in agent_init.py

Design Principle

Memory is the most unsafe place. When DB persistence fails, get messages to disk immediately — JSONL fallback is the last line of defense before data loss. Dedup via shared timestamp guarantees safe replay on restart.

Related work

@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 comp/gateway Gateway runner, session dispatch, delivery labels May 26, 2026
@mlinquan
mlinquan force-pushed the fix/session-db-fallthrough branch 2 times, most recently from 7fe0328 to 67e3a47 Compare May 28, 2026 16:20
@liuhao1024

Copy link
Copy Markdown
Contributor

I found one issue that looks worth fixing before merge.

run_agent.py:_ensure_db_session — the recovery log always reports "0 failures":

if self._session_db_failed:
    self._session_db_failed = False
    self._session_db_create_fail_count = 0          # ← resets to 0
    logger.info("Session DB creation recovered after %d failures",
                self._session_db_create_fail_count)  # ← always logs 0

The counter is zeroed before the log statement reads it, so the message always says "recovered after 0 failures" regardless of how many consecutive failures actually occurred.

Why it matters: Operators debugging degraded session persistence rely on this log to understand recovery timing. A misleading "0 failures" message hides the real severity — a session that failed 50 times before recovering looks identical to one that never failed.

Suggested fix:

if self._session_db_failed:
    failed_count = self._session_db_create_fail_count
    self._session_db_failed = False
    self._session_db_create_fail_count = 0
    logger.info("Session DB creation recovered after %d failures", failed_count)

@mlinquan
mlinquan force-pushed the fix/session-db-fallthrough branch 2 times, most recently from 3e6bc8f to f9e00fb Compare May 29, 2026 13:00
@mlinquan mlinquan changed the title fix: prevent silent message loss when state.db session creation fails fix: prevent silent message loss when state.db persistence fails May 29, 2026
@mlinquan

mlinquan commented Jun 1, 2026

Copy link
Copy Markdown
Author

@alt-glitch Week-old ping — ready to merge?

mlinquan added 12 commits June 16, 2026 15:39
Three-fold protection against _ensure_db_session and append_message
failures that previously caused silent, unrecoverable message loss:

1. _flush_messages_to_session_db: per-message try/except with break on
   failure. _last_flushed_db_idx now increments per-msg instead of
   bulk-assign, preserving partial progress. Sets _session_db_failed
   on any append failure.

2. _persist_session: immediately writes unflushed messages to a JSONL
   fallback file when _session_db_failed is set. Profile-isolated via
   HERMES_PROFILE env var.

3. Gateway agent_persisted check: now requires _session_db_created AND
   !_session_db_failed, not just _session_db is not None. Prevents
   the gateway from trusting an agent whose DB writes failed.

Includes 8 unit tests covering append failure, JSONL fallback,
profile isolation, and agent_persisted logic.
- Add timestamp support to SessionDB.append_message
- Save pending messages with wrapped timestamp format
- Implement pending message recovery in gateway runner
- Fix _last_flushed_db_idx update on partial failure
- Archive pending files after recovery instead of deleting
- Profile-aware pending file lookup
- Backward compatible with old pending file format
Use max-overlap-prefix to detect already-persisted messages
before inserting, preventing duplicate writes on recovery.
Match key: (role, content, tool_name, tool_calls, tool_call_id)
Generate timestamp once before append_message call, store it on the
message dict as _flush_timestamp, and pass it explicitly to both the
DB insert and the JSONL fallback. Recovery now matches on (role,
content, timestamp) so genuine repeats with identical content but
different timestamps are preserved.
Previously the flag was sticky — once set it never cleared for the
agent's lifetime, causing unnecessary JSONL fallback writes on every
subsequent _persist_session call even after DB writes recovered.
…ticky flag

Eliminate _session_db_failed flag entirely. When an append_message call
fails inside _flush_messages_to_session_db, unflushed messages are
written to the pending JSONL file immediately in the except block using
last_successful_idx as the start cursor. The _persist_session caller
only triggers fallback when _session_db is None entirely.

This removes the sticky-flag false-positive problem where a transient
failure in turn N caused unnecessary fallback writes in every subsequent
turn even after the DB recovered.
…ackground review agent

- _execute_write now retries on 'readonly'/'read-only' OperationalError,
  covering macOS WAL mode where multi-process contention returns
  SQLITE_READONLY instead of SQLITE_BUSY

- background review sub-agent inherits parent's _session_db so its
  _persist_session calls write to state.db instead of pending fallback
  (the sub-agent reuses the parent session_id but was missing the DB
  handle, causing all messages to land in .pending.jsonl)

- gateway: distinguish lock contention from other SessionDB init
  failures in log output
@mlinquan
mlinquan force-pushed the fix/session-db-fallthrough branch from f9ac91f to e35e056 Compare June 16, 2026 07:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants