mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
* fix(sessions): proactive prune rewrites only the changed rows, in place A prune edits the content of a few existing messages and never adds, drops or reorders one, yet every commit went through archive_and_compact, which archives all active rows and re-inserts the full list: state.db grew by the whole live transcript per commit (#124102). SessionDB.rewrite_pruned_rows resolves each changed message to its live row (_row_id, else its tool call id or tool-call ids, else the single active row with the same role and stored content), checks the row still holds the original, inserts one archived twin of the pre-prune row (active=0, compacted=1, still searchable) and updates the changed columns under the same id. The prune tries it after the stale-history watermark check and falls back to archive_and_compact when a row cannot be named exactly or an unwritten message sits before durable ones. Fixes #124102 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sessions): in-place prune twin keeps the live row's display slot The content UPDATE fires messages_display_identity_update, which nulls display_identity/display_order on the live row and on its freshly inserted twin. The lazy backfill then gives the twin its own id as display_order, so every demoted full body renders at the END of the transcript page, after the newest user turn (3 x 24 KB bodies below "from desktop" in the probe). Re-stamp both rows with the pre-update identity and order inside the same transaction: the page projection shows one row per display_order (the live stub, in place) and the twin stays a searchable archive. This is the same stored-identity shape #117824 proposes for the archive writer (#117750), so the resume projection collapses the pair once that lands. Review-fix for #124273; one assertion added to the growth invariant (red on the PR head: 20 page rows for 17 messages). * fix(sessions): in-place prune keeps each display slot through later compaction and backfill Review follow-up (ehz0ah): the rewritten live row kept the archived body's identity, so a later archive_and_compact gave its copy a fresh slot and the full body resurfaced in the old one. The live row now takes the identity of what it stores and keeps the slot; the twin and any earlier generation's copies (all nulled by the identity trigger) are restamped with their own identity and slot, and the display backfill keeps a row's stored slot when its stored identity is still correct. The prune pre-fills slots for legacy rows so it never carries a missing one. * fix(agent): fail closed when an in-place prune finds the row rewritten rewrite_pruned_rows raised one PruneRowUnresolvedError for two different conditions: the row cannot be named (ambiguity — the full writer handles it) and the row IS named but its stored body changed. The second means another surface already rewrote this generation, which is what StaleHeldHistory exists to catch; the in-place prune preserves row ids, so _archive_watermark_for cannot see it. Returning None there handed the stale pruned_msgs to archive_and_compact, which archives rows it never compared and republished the held text over a newer durable edit. Split the stale case into a PruneRowStaleError subclass and raise StaleHeldHistory from the in-place helper so the existing handler skips the pass instead of committing. Ambiguity keeps the fallback. Replaces the prune-fallback regression with the two cases review asked for: a changed stored body skips the prune and leaves the newer body active; a genuine ambiguity over current content still commits through the full writer. * fix(agent): make an in-place prune visible to the other lease-less writer rewrite_pruned_rows keeps every row id and activity flag, so the id/activity staleness gate in held_archive_watermark cannot see that a prune already committed. A second lease-less surface (micro-compaction) holding the pre-prune transcript passes the gate and archive_and_compact republishes the pruned-away bodies over the winner, undoing the prune. stale_raises now also compares the held bodies against the stored ones via the new SessionDB.rewritten_held_row_ids, so every lease-less writer sees the in-place generation change, not just the prune caller that owns the exception. * fix(state): compare durable row versions, not raw content, in the in-place stale guard Reviewers on 631c897b converged on one root defect: the stale-write guard compared raw SQLite bytes against a loaded/normalized view, which both missed real rewrites and invented phantom ones. - rewrite_pruned_rows._resolve: the staleness check is now the repo's stored-row CAS digest (transcript_row_snapshot over a SELECT * row, covering every owned column including assistant tool_calls arguments) when the held dict carries DB_ROW_SNAPSHOT, and the loader-lens identity compare (_row_identity, the same representation _stamp_kept_live_prefix uses) otherwise. Raw storage bytes are never compared against a loaded view. - The rewritten row's replacement dict is stamped with the row's new digest, and the commit runs under _execute_transcript_write so a rollback restores the stamps. - rewritten_held_row_ids takes full held dicts and applies the same two-leg compare; only tool/assistant rows (the roles the in-place writer touches) are compared. - held_archive_watermark(stale_raises=True) checks the durable prefix BEFORE the ids[-1] is None early return, so an unstamped trailing message caps the watermark but can no longer suppress version checks for the rows the caller holds exactly. Closes the P0 stale tool_calls path, the P2 false-stale on freshly resumed whitespace-padded rows, and the unstamped-tail blind spot (#124102). * fix(state): gate the lease-less stale check on every role the fallback republishes The staleness gate behind held_archive_watermark (rewritten_held_row_ids) compared only tool/assistant held rows, but the fallback it guards — archive_and_compact, reached whenever the in-place rewrite refuses (an unwritten message before later durable rows, an unnamed row) — re-inserts held rows of every model-transcript role verbatim. A user row another view rewrote in place (set_user_message_content, the repair batch's in-place rewrite) slipped the gate: the stale pre-edit body was republished active and the newer edit left with zero active copies. Compare every model-transcript role (user/assistant/tool); the per-row comparison (DB_ROW_SNAPSHOT CAS, else the loader-lens identity) is already role-agnostic. Bookkeeping roles stay excluded — they never reach the model-facing list. Covers the prune fallback and micro-compaction, the two lease-less callers of _archive_watermark_for (#124102). Sabotage-run: reverting the role filter makes test_a_concurrent_user_edit_stays_active_when_the_prune_falls_back fail (stale body published); restored, the file is 22/22 green. * style: use builtin generics in the in-place prune commit annotations * refactor(compression): split the proactive prune and its commit into topical siblings The code-health ratchet (CI) flags this PR's own growth: hermes_state_messages.py (2190 > 2000), agent/context_compressor.py (5911 > its main value 5852) and agent/conversation_compression.py (4585 > 4564). The remedy the repo prescribes: move the PR's own code into topical siblings, keeping the facade as the seam. - agent/context_compressor_prune.py: ProactivePruneMixin (prune_tool_results_only + _rewrite_pruned_rows_in_place), bound into ContextCompressor; origin-module symbols read through _cc() at call time (the micro_compaction.py pattern) so tests patching agent.context_compressor.X stay effective; logger keeps the agent.context_compressor name for caplog parity. - agent/conversation_compression_watermark.py: held_archive_watermark; the origin module imports it and keeps _held_watermark (its only caller). - hermes_state_prune_rewrite.py: SessionPruneRewriteMixin (rewrite_pruned_rows + rewritten_held_row_ids), bound into SessionDB; every SessionDB call site and test patch seam (db.rewrite_pruned_rows / db.rewritten_held_row_ids) unchanged. Verified: scripts/check --only health --base origin/main -> 0 blocking; ruff clean; prune/compaction batches green (test_proactive_prune_restart_safety 22/22, test_micro_compaction 48/48, five proactive-prune files 25/25). The 4 failures in test_compaction_prompt_rebuild / test_compression_review reproduce identically on origin/main (pre-existing). * fix(state): re-verify held rows inside the full writer's commit transaction The lease-less stale gate (rewritten_held_row_ids) runs outside the write transaction, so a second SessionDB connection that rewrites a covered row AFTER the precheck but BEFORE archive_and_compact's BEGIN IMMEDIATE was invisible to it: the commit archived the newer row and republished the stale held text, leaving the newer edit with zero active copies (#124102, review finding on 8c6da464). - archive_and_compact takes held_for_cas: the caller's held dicts, re-verified against the live rows INSIDE the commit's transaction before any row moves; a mismatch raises HeldRowRewrittenError, which the two lease-less callers (proactive prune fallback, micro-compaction sync) treat exactly like StaleHeldHistory - nothing written, the pass is a no-op and the newer generation keeps the session. The lease path leaves it None: its lease rules out an overlapping rewrite. - held_row_mismatches(conn, ...) is the compare lifted out of rewritten_held_row_ids so both the read-path precheck and the in-transaction fence share one implementation. It now chunks the id IN query via _id_chunks (a history past SQLite's host-parameter limit failed the pass instead of reclaiming) and resolves rows that have no _row_id but do carry the persist marker + CAS snapshot by their logical message_uid (idx_messages_session_uid) - the unaddressed live-replay loader shape, whose rows were previously exempt from the fence entirely. - regression: test_a_user_edit_landing_after_the_precheck_stays_active_in_the_commit drives a second SessionDB connection's set_user_message_content at the archive_and_compact call boundary; sabotage (gate disabled) makes it fail exactly as the finding predicts. * refactor(compression): drop the duplicate _held_watermark from the module split The 8c6da464 sibling extraction left two identical _held_watermark definitions back to back in agent/conversation_compression.py; Python silently kept only the second, so editing the first had no effect (dead code, root AGENTS.md). Remove the added copy. * refactor(state): split the api_content sidecar writers into a topical sibling hermes_state_messages.py crossed the FILE_LINES ratchet (2010 > 2000) after the commit-window fence landed in it. Move set_latest_user_api_content / set_message_api_content into hermes_state_api_content.py (SessionApiContentMixin, bound into SessionDB in the same MRO slot): the two row-addressed api_content sidecar UPDATEs are one topic, no behaviour change, every call site and test patch seam unchanged. * fix(state): flatten _archive_and_compact_on_conn onto the caller's transaction after the main merge Main flattened this helper to run directly on the caller's write connection (no nested _execute_transcript_write); the merge resolution had kept the old nested shape, which opened a second BEGIN IMMEDIATE inside the live one and deadlocked every archive_and_compact. Re-flattened while keeping the #124102 held_for_cas in-transaction CAS fence. * fix(state): carry the held-row CAS fence through the worker RPC path RuntimeSessionCompressionMixin.archive_and_compact rejected held_for_cas, so a worker-side micro-compaction commit raised TypeError inside the mixin's generic except and silently skipped the whole persist (unstamped marker, durable transcript left un-compacted). Accept held_for_cas, project it onto the closed handoff fields plus the persistence marker the CAS compare reads, validate it in worker_archive, and thread it into archive_on_connection -> _archive_and_compact_on_conn (#124102). * test(micro): a stale held generation without row ids aborts the micro-compact Review follow-up (ehz0ah/greptile-apps, PR #124273): ACP restore and OpenAI session continuation load through the unaddressed live-replay projection (get_messages_as_conversation without include_row_ids=True) — the persist marker, durable message_uid and the stored-row CAS digest, but no _row_id. held_row_mismatches already resolves that shape by logical message_uid, so the lease-less fence still sees another surface's in-place prune. Pin it with the regression both reviewers asked for: after another surface prunes in place, micro-compaction from that stale no-row-id history is a true no-op — the pruned stubs stay the only live generation and the pre-prune bodies are not republished (#124102). Sabotage: disabling the by-uid branch in held_row_mismatches fails this test (the sync republishes the stale bodies); restored, 49/49 pass. Co-authored-by: ehz0ah <ehz0ah@users.noreply.github.com> * fix(sessions): the uid-resolved stale fence CASes the newest active row held_row_mismatches resolved an id-less held dict by its logical message_uid with a bare fetchone(): when a carried or concurrent compaction has cloned the row back beside its archived twin, one uid names several active physical rows, and the uid index returns them oldest-first. The fence then CASed the oldest copy — which still held the payload the held digest was stamped from — and cleared a held generation as current although another writer had rewritten the newest row; the lease-less fallback republished the stale held payload over that newer generation (#124102). Resolve with the same newest-active rule as the loader's _active_logical_message_row (ORDER BY id DESC LIMIT 1). Two regressions pin it: two active rows sharing a uid where only the newest was rewritten in place must abort the micro-compact naming the newest row id, and where only an older duplicate moved the held digest must stay current. Review follow-up to greptile-apps' 2026-10-17 P1 on PR #124273. --------- Co-authored-by: defurniture2025 <301948806+defurniture2025@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> Co-authored-by: Austin Pickett <pickett.austin@gmail.com> Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com> Co-authored-by: ehz0ah <ehz0ah@users.noreply.github.com>
327 lines
16 KiB
Python
327 lines
16 KiB
Python
"""Exception types and error-classification predicates for the state store.
|
|
Shared by hermes_state and its mixins; string predicates match wrapped RPC
|
|
strings as well as live sqlite3 exceptions."""
|
|
|
|
import errno
|
|
import re
|
|
import sqlite3
|
|
|
|
# Malformed schema: ``sqlite_master`` itself is inconsistent (typically a DUPLICATE
|
|
# ``CREATE VIRTUAL TABLE messages_fts`` row). SQLite parses the whole schema while
|
|
# preparing the FIRST statement, so EVERY statement raises (even ``PRAGMA
|
|
# journal_mode`` during __init__); only ``PRAGMA writable_schema=ON`` +
|
|
# sqlite_master surgery still work. Canonical rows are intact; recovery rebuilds
|
|
# only the FTS layer.
|
|
_MALFORMED_SCHEMA_MARKERS = ("malformed database schema",)
|
|
_MALFORMED_DB_MARKERS = (*_MALFORMED_SCHEMA_MARKERS, "database disk image is malformed")
|
|
|
|
|
|
def is_malformed_db_error(exc: BaseException) -> bool:
|
|
"""Malformed-schema OR generic corrupt-image error. Diagnostics / offline
|
|
recovery only — runtime repair must use :func:`is_malformed_schema_error`."""
|
|
return isinstance(exc, sqlite3.DatabaseError) and any(
|
|
marker in str(exc).lower() for marker in _MALFORMED_DB_MARKERS
|
|
)
|
|
|
|
|
|
# SQLITE_IOERR as a substring (wrapped strings still classify).
|
|
_DISK_IO_ERROR_MARKER = "disk i/o error"
|
|
|
|
# "Store BUSY, not gone" — HTTP callers map these to 503 instead of 500. Corruption
|
|
# is deliberately absent: a malformed store must surface, not be retried into a timeout.
|
|
_TRANSIENT_SQLITE_MARKERS = (
|
|
_DISK_IO_ERROR_MARKER, "database is locked", "database table is locked", "busy",
|
|
)
|
|
|
|
|
|
# Lock contention by result code. SQLite keeps SQLITE_BUSY when FTS5's xConnect loses the race
|
|
# on its %_config read but replaces the text with "vtable constructor failed: messages_fts",
|
|
# so a phrase match read a busy store as a hard failure.
|
|
_SQLITE_LOCK_CODES = (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED)
|
|
|
|
|
|
def _sqlite_primary_code(exc_or_str) -> int | None:
|
|
"""Primary result code (extended codes keep it in the low byte); None when unknown."""
|
|
code = getattr(exc_or_str, "sqlite_errorcode", None)
|
|
return code & 0xFF if isinstance(code, int) else None
|
|
|
|
|
|
def is_sqlite_lock_error(exc_or_str) -> bool:
|
|
"""SQLITE_BUSY / SQLITE_LOCKED: wait and retry, never treat as damage. A known result code
|
|
decides; only without one (our own re-raised messages, RPC-wrapped strings) does the text."""
|
|
code = _sqlite_primary_code(exc_or_str)
|
|
if code is not None:
|
|
return code in _SQLITE_LOCK_CODES
|
|
text = str(exc_or_str).lower()
|
|
return "locked" in text or "busy" in text
|
|
|
|
|
|
def _is_no_more_rows(exc: sqlite3.Error) -> bool:
|
|
"""Transient engine error on contended WAL appends (retries like locked/busy);
|
|
message-scoped because some builds raise it as InterfaceError."""
|
|
return "no more rows available" in str(exc).lower()
|
|
|
|
|
|
def is_transient_sqlite_error(exc: BaseException) -> bool:
|
|
""""Busy right now", not "damaged": one predicate so retry and the HTTP
|
|
503-vs-500 split cannot drift apart."""
|
|
return isinstance(exc, sqlite3.OperationalError) and (
|
|
is_sqlite_lock_error(exc) or any(marker in str(exc).lower() for marker in _TRANSIENT_SQLITE_MARKERS)
|
|
)
|
|
|
|
|
|
def is_malformed_schema_error(exc: BaseException) -> bool:
|
|
"""Only SQLite's explicit malformed-schema text: a generic "disk image is
|
|
malformed" may be any B-tree page, so runtime repair must fail closed on it."""
|
|
return isinstance(exc, sqlite3.DatabaseError) and any(
|
|
marker in str(exc).lower() for marker in _MALFORMED_SCHEMA_MARKERS
|
|
)
|
|
|
|
|
|
# "Filesystem cannot accept another write" substrings (OSError, sqlite3, wrapped RPC strings).
|
|
_DISK_FULL_MARKERS = (
|
|
"no space left on device", "not enough space", "database or disk is full", # SQLITE_FULL
|
|
"disk full", "full disk", "enospc",
|
|
)
|
|
|
|
|
|
def is_disk_full_error(exc: BaseException | str | None) -> bool:
|
|
"""Disk-full / ENOSPC: OSError(ENOSPC), SQLITE_FULL, or matching strings."""
|
|
if exc is None:
|
|
return False
|
|
if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.ENOSPC:
|
|
return True
|
|
lowered = (exc if isinstance(exc, str) else str(exc)).lower()
|
|
return any(marker in lowered for marker in _DISK_FULL_MARKERS)
|
|
|
|
|
|
# Every classify_persistence_error bucket; consumers enumerate this tuple.
|
|
PERSISTENCE_ERROR_CAUSES = (
|
|
"locked", "compression", "compression_closed", "turn_lease", "corrupt", "fts_index",
|
|
"replaced", "deleted_wal", "disk", "session_row_missing", "unknown",
|
|
)
|
|
|
|
|
|
# "Database FILE structurally damaged" substrings. "database disk image is
|
|
# malformed" contains "disk", so this check MUST run before the disk bucket in
|
|
# classify_persistence_error or B-tree corruption reads as "free some disk space".
|
|
# Kept as plain substrings so sqlite3.DatabaseError, wrapped RPC strings, and logged message text all match
|
|
# the same helper. See #77386.
|
|
_DB_CORRUPTION_MARKERS = (
|
|
"malformed", "file is not a database", "not a database", "database corruption",
|
|
)
|
|
|
|
# The module constant exists on Python 3.11+; the numeric value is stable across SQLite releases.
|
|
SQLITE_CORRUPT_VTAB = getattr(sqlite3, "SQLITE_CORRUPT_VTAB", 267)
|
|
|
|
# Every FTS object hangs off this prefix: the virtual tables and their _data/_idx/_content/
|
|
# _docsize/_config shadow b-trees. FTS5 names the table in its own corruption reports.
|
|
_FTS_OBJECT_RE = re.compile(r"\bmessages_fts\w*")
|
|
|
|
|
|
def is_fts_scoped_corruption_error(exc_or_str) -> bool:
|
|
"""Corruption SQLite itself attributes to the FTS index layer: the ONE provenance rule
|
|
shared by the write-repair gate (``SessionDB._is_fts_write_corruption_error``), the
|
|
gateway transcript retry and :func:`classify_persistence_error` (#96038, #97794).
|
|
|
|
A known result code outranks prose: ``SQLITE_CORRUPT_VTAB`` is FTS-scoped even with
|
|
the generic malformed-image text older SQLite builds emit, while bare ``SQLITE_CORRUPT``
|
|
/ ``SQLITE_NOTADB`` carry no object scope and any other known code contradicts
|
|
FTS-looking prose, so both fail closed. Only without a code (Python < 3.11, RPC-wrapped
|
|
strings) does the text decide, and then only an ``fts5:`` corruption report or a
|
|
corruption marker that names a ``messages_fts*`` object counts.
|
|
"""
|
|
if exc_or_str is None:
|
|
return False
|
|
code = getattr(exc_or_str, "sqlite_errorcode", None)
|
|
if code is not None:
|
|
return code == SQLITE_CORRUPT_VTAB
|
|
text = (exc_or_str if isinstance(exc_or_str, str) else str(exc_or_str)).lower()
|
|
if not _FTS_OBJECT_RE.search(text):
|
|
return False
|
|
if text.startswith("fts5:") and "corrupt" in text:
|
|
return True
|
|
return any(marker in text for marker in _DB_CORRUPTION_MARKERS)
|
|
|
|
|
|
class CompressionSessionClosedError(RuntimeError):
|
|
"""A durable write targeted a parent already closed by compression."""
|
|
|
|
def __init__(self, session_id: str):
|
|
self.session_id = session_id
|
|
super().__init__(
|
|
f"Session {session_id!r} is closed by compression; "
|
|
"adopt its live continuation before appending messages"
|
|
)
|
|
|
|
|
|
class CompressionSessionBusyError(RuntimeError):
|
|
"""A non-owner tried to write while compression owns the session."""
|
|
|
|
|
|
class SessionCompressionInProgressError(CompressionSessionBusyError):
|
|
"""A concurrent writer collided with a *live* compression lock — transient
|
|
(the compressor publishes in seconds; ``_execute_write`` waits), unlike the
|
|
parent class's other case (a compressor whose own lease is gone: permanent,
|
|
fail fast). Subclassing keeps every existing handler working."""
|
|
|
|
|
|
class PruneRowUnresolvedError(RuntimeError):
|
|
"""An in-place prune rewrite could not name the exact live row of a changed message. Raised before
|
|
any write, so the caller commits through ``archive_and_compact`` instead."""
|
|
|
|
|
|
class PruneRowStaleError(PruneRowUnresolvedError):
|
|
"""The named live row no longer holds the body the pruner is pruning: another writer already
|
|
rewrote this generation. Unlike plain ambiguity this must NOT fall back to the full writer —
|
|
that writer archives rows it never compared and would republish the stale held text over the
|
|
newer one. Subclassing keeps callers that only know the parent failing closed-enough (they stop
|
|
the in-place path); the prune caller tells the two apart and skips the pass entirely."""
|
|
|
|
|
|
class HeldRowRewrittenError(RuntimeError):
|
|
"""A full-writer commit found one of the held rows it was about to archive already rewritten in
|
|
place by another writer. Raised INSIDE the commit's write transaction, after the caller's
|
|
precheck but before any row is archived: a write that lands in that window is exactly the lost
|
|
race the precheck cannot see (#124102). Callers treat it like ``StaleHeldHistory`` — nothing
|
|
written, the pass is a no-op and the newer generation keeps the session."""
|
|
|
|
|
|
class SessionTurnLeaseLostError(RuntimeError):
|
|
"""A transcript write presented a turn-lease holder that no longer owns it.
|
|
Fail-fast fencing (no ``_execute_write`` retry): a later writer may already
|
|
be persisting a newer turn, and landing this one would interleave a stale reply."""
|
|
|
|
|
|
class SessionActiveWriteGuardError(RuntimeError):
|
|
"""Raised when an active turn lease or compression lock rejects session deletion."""
|
|
|
|
|
|
class StateDbReplacedError(RuntimeError):
|
|
"""The state.db path no longer names the file this SessionDB opened
|
|
(out-of-band cp/mv/restore). In-place FTS repair and fail-open trigger
|
|
dropping cannot fix a generation mismatch; they amplify it."""
|
|
|
|
|
|
class DeletedWalGenerationError(StateDbReplacedError):
|
|
"""A live process holds a deleted state.db-wal / -shm generation. Opening or
|
|
writing through this handle would mint a second WAL inode (split-brain ->
|
|
intermittent SQLITE_CORRUPT / IOERR). Stop the writers; never unlink the WAL
|
|
yourself. Subclasses StateDbReplacedError so every consumer that diverts
|
|
transcripts on a replaced store handles this identically."""
|
|
|
|
|
|
# SQLite header application_id (offset 68). Distinct from inode: ``cp`` onto the
|
|
# same path keeps st_ino and truncates+rewrites.
|
|
_STATE_DB_APPLICATION_ID_OFFSET = 68
|
|
_STATE_DB_GENERATION_KEY = "db_file_generation"
|
|
_STATE_DB_REPLACED_MSG = (
|
|
"FATAL: state.db was replaced underneath the gateway; refusing further "
|
|
"writes to this file. Divert transcripts to sessions/<id>.jsonl (and the "
|
|
"gateway pending_messages spool) and restore or reopen after operator intervention."
|
|
)
|
|
STORAGE_RECOVERY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/session-storage-recovery"
|
|
|
|
# Two layers (#110054): the first sentence is for the person reading a chat bubble or a banner (what
|
|
# happened, nothing is lost, the one thing to do); the rest is the operator detail. The phrase
|
|
# "deleted state.db-wal or state.db-shm" is the classifier's RPC-wrapped fingerprint — keep it.
|
|
_DELETED_WAL_GENERATION_MSG = (
|
|
"FATAL: session storage stopped writing because another Hermes process still holds a deleted "
|
|
"state.db-wal or state.db-shm inode (an old copy of the write-ahead log). Nothing is lost: quit "
|
|
"every Hermes process on this profile (Desktop app, gateway, dashboard, cron), run `hermes doctor` "
|
|
"(it names the processes still holding the log), then start Hermes again. Do not delete the WAL "
|
|
"yourself and do not run `hermes doctor --fix` while they are running. "
|
|
f"Guide: {STORAGE_RECOVERY_DOCS_URL} "
|
|
"Detail: the path names a different (or missing) generation than the one this process holds "
|
|
"open; opening or writing through it would mint a second WAL (split-brain). "
|
|
"database.journal_mode: delete is operator containment, not a new default."
|
|
)
|
|
|
|
|
|
class StateDbCorruptError(sqlite3.DatabaseError):
|
|
"""A live SessionDB observed structural (non-FTS, non-replaced) corruption and
|
|
is quarantined: sticky for the handle's life — writes fail fast, no reopen,
|
|
no close-time checkpoint (a handle that kept writing after the first error
|
|
checkpointed 15 pages under wrong page numbers and turned a readable file
|
|
into "file is not a database"; SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE on 3.12+
|
|
also stops SQLite's own). Subclasses sqlite3.DatabaseError so every degrade
|
|
path keeps working. Recovery boundary: restart on a repaired/restored file.
|
|
|
|
Stopping the writes is what prevents that; skipping the explicit checkpoint is the second line of
|
|
defence. SQLite still runs its own last-connection checkpoint inside ``close()`` (and deletes the
|
|
``-wal`` sidecar) unless ``SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE`` is set — Python exposes it via
|
|
``Connection.setconfig()`` on 3.12+, so quarantine disables the close-time checkpoint there and the WAL
|
|
survives on disk for forensics; on 3.11 the internal checkpoint is unavoidable (post-quarantine it can
|
|
only carry pre-corruption committed frames, since no further writes are accepted). See #90837.
|
|
"""
|
|
|
|
|
|
_STATE_DB_CORRUPT_MSG = (
|
|
"FATAL: state.db reported structural corruption (database disk image is "
|
|
"malformed outside the FTS shadow tables) on a live handle; refusing further "
|
|
"writes, automatic reopen, and the close-time WAL checkpoint on this file. "
|
|
"Stop the gateway, then run `hermes sessions recover --source <state.db> "
|
|
"--inspect-only` or restore a snapshot. Unwritten transcripts are diverted to "
|
|
"sessions/<id>.jsonl (and the gateway pending_messages spool)."
|
|
)
|
|
|
|
|
|
_PERSISTENCE_CAUSE_BY_TYPE = (
|
|
(SessionTurnLeaseLostError, "turn_lease"),
|
|
(CompressionSessionClosedError, "compression_closed"),
|
|
(CompressionSessionBusyError, "compression"),
|
|
# The WAL-generation error subclasses StateDbReplacedError so existing write diversion keeps
|
|
# working; classify it first because its recovery artifact and operator action are different.
|
|
(DeletedWalGenerationError, "deleted_wal"),
|
|
(StateDbReplacedError, "replaced"),
|
|
(StateDbCorruptError, "corrupt"),
|
|
)
|
|
_PERSISTENCE_CAUSE_BY_PHRASE = (
|
|
(("turn lease",), "turn_lease"),
|
|
(("closed by compression",), "compression_closed"),
|
|
(("being compressed", "compression lease"), "compression"),
|
|
# RPC-wrapped errors lose their exception type; retain the same sidecar/main-file split.
|
|
(("deleted state.db-wal", "deleted state.db-shm"), "deleted_wal"),
|
|
(("was replaced underneath",), "replaced"),
|
|
(_DB_CORRUPTION_MARKERS, "corrupt"),
|
|
(("locked", "busy"), "locked"),
|
|
# A flush rejected by the session-row FK: the row was removed under a live agent (#123583).
|
|
(("foreign key constraint failed",), "session_row_missing"),
|
|
)
|
|
|
|
|
|
def classify_persistence_error(exc_or_str) -> str:
|
|
"""Coarse cause bucket (PERSISTENCE_ERROR_CAUSES) so the user's guidance
|
|
matches: "locked" = busy, retry; "disk" = full/read-only/permissions;
|
|
"compression" = a live lease refused the write; "compression_closed" = adopt
|
|
the rotated session id; "turn_lease" = fencing, not storage; "corrupt" =
|
|
file damage (repair path, not disk space); "fts_index" = SQLite scoped the
|
|
corruption to the FTS index (the transcript store is not damaged); "replaced" =
|
|
main-file replacement; "deleted_wal" = a retired sidecar generation requiring
|
|
capture inspection; "session_row_missing" = the session row was deleted under a
|
|
live agent (FK rejection; the flush recreates it)."""
|
|
if exc_or_str is None:
|
|
return "unknown"
|
|
# Lease refusals contain neither "locked" nor "busy": match by type first,
|
|
# then by phrase for strings that survived RPC wrapping. Order matters:
|
|
# StateDbReplacedError covers DeletedWalGenerationError; corruption comes
|
|
# BEFORE the lock/disk buckets ("disk image is malformed" contains "disk").
|
|
for exc_type, cause in _PERSISTENCE_CAUSE_BY_TYPE:
|
|
if isinstance(exc_or_str, exc_type):
|
|
return cause
|
|
# Provenance before prose: an FTS-scoped result code (or, without one, an fts5 report
|
|
# naming messages_fts*) is index damage, never whole-file corruption (#97794).
|
|
if is_fts_scoped_corruption_error(exc_or_str):
|
|
return "fts_index"
|
|
if _sqlite_primary_code(exc_or_str) in _SQLITE_LOCK_CODES:
|
|
return "locked"
|
|
if getattr(exc_or_str, "sqlite_errorcode", None) == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY:
|
|
return "session_row_missing"
|
|
text = str(exc_or_str).lower()
|
|
for markers, cause in _PERSISTENCE_CAUSE_BY_PHRASE:
|
|
if any(marker in text for marker in markers):
|
|
return cause
|
|
if is_disk_full_error(exc_or_str) or any(m in text for m in ("disk", "readonly", "read-only")):
|
|
return "disk"
|
|
return "unknown"
|