diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 1d27e62d70..fbdf19cd91 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -737,7 +737,7 @@ def finalize_turn( "health (`hermes doctor`), then send your message again" ) # Machine-readable cause for the gateway/desktop: exactly - # 'session_persistence_failed:'. + # 'session_persistence_failed:'. # Never clobber a failure_reason another path already stamped. if "failure_reason" not in result: _cause = getattr(agent, "_last_persistence_error_cause", None) diff --git a/hermes_state.py b/hermes_state.py index 73d3d9d917..b284e0046a 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1605,11 +1605,27 @@ PERSISTENCE_ERROR_CAUSES = ( "compression", "compression_closed", "turn_lease", + "corrupt", "disk", "unknown", ) +# Markers that mean the database FILE itself is structurally damaged. Kept +# as plain substrings so sqlite3.DatabaseError, wrapped RPC strings, and +# logged message text all match the same helper. NOTE: "database disk image +# is malformed" contains the word "disk", so this check MUST run before the +# disk-full/readonly bucket in classify_persistence_error — otherwise real +# B-tree corruption gets reported to the user as "free some disk space" +# (the misdiagnosis documented on #77386). +_DB_CORRUPTION_MARKERS = ( + "malformed", # "database disk image is malformed" (SQLITE_CORRUPT) + "file is not a database", # SQLITE_NOTADB (also connection-level poisoning) + "not a database", + "database corruption", +) + + def classify_persistence_error(exc_or_str) -> str: """Classify a session-persistence failure into a coarse cause bucket. @@ -1631,6 +1647,10 @@ def classify_persistence_error(exc_or_str) -> str: * ``"turn_lease"`` — a presented session-turn-lease holder no longer owns the conversation (expired, released, or reclaimed); fail-fast fencing, not a storage fault. + * ``"corrupt"`` — the database file itself is structurally damaged + (``database disk image is malformed`` / SQLITE_NOTADB). Distinct from + ``"disk"``: freeing space cannot help, the user needs the repair path + (``hermes doctor`` / automatic schema surgery). * ``"disk"`` — disk full / read-only / permission-shaped failures (delegates the disk-full patterns to :func:`is_disk_full_error` so the two classifiers can never drift apart — e.g. ENOSPC). @@ -1656,6 +1676,12 @@ def classify_persistence_error(exc_or_str) -> str: return "compression_closed" if "being compressed" in text or "compression lease" in text: return "compression" + # Structural corruption BEFORE the lock and disk buckets: "database disk + # image is malformed" contains "disk" (and some wrapped corruption + # strings mention "locked" recovery attempts), so later buckets would + # steal it and misdiagnose damage as space/contention. + if any(marker in text for marker in _DB_CORRUPTION_MARKERS): + return "corrupt" if ( "locked" in text or "busy" in text diff --git a/run_agent.py b/run_agent.py index f26384dfab..7bfc418249 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3865,6 +3865,15 @@ class AIAgent: "database). Your message should already be saved — " "please send it again in a moment." ) + if cause == "corrupt": + return ( + prefix + + "the turn was stopped because the state database " + "reported structural corruption (the transcript would " + "have been lost on restart). Freeing disk space will " + "not help — run `hermes doctor` to repair the state " + "database, then send your message again." + ) if cause == "disk": return ( prefix diff --git a/tests/run_agent/test_turn_completion_explainer.py b/tests/run_agent/test_turn_completion_explainer.py index 1a245ae786..6e2c889de4 100644 --- a/tests/run_agent/test_turn_completion_explainer.py +++ b/tests/run_agent/test_turn_completion_explainer.py @@ -140,6 +140,20 @@ def test_explanation_persistence_disk_cause_keeps_disk_wording(): assert "free some space" in lower or "disk space" in lower +def test_explanation_persistence_corrupt_cause_never_says_free_space(): + """Structural corruption must point at the repair path, not disk space + (the #77386-family misdiagnosis: 'database disk image is malformed' + rendered as 'this is often a full disk').""" + out = AIAgent._format_turn_completion_explanation( + "session_persistence_failed", "corrupt" + ) + lower = out.lower() + assert "corrupt" in lower + assert "hermes doctor" in lower + assert "free some space" not in lower + assert "full disk" not in lower + + def test_explanation_persistence_unknown_cause_is_neutral(): """None/'unknown' cause must not claim disk-full — point at diagnostics.""" for cause in (None, "unknown"): @@ -199,6 +213,30 @@ def test_classify_persistence_error_categories(): assert classify_persistence_error("") == "unknown" +def test_classify_persistence_error_corruption_beats_disk_bucket(): + """'database disk image is malformed' contains the word 'disk', so + without an explicit corruption bucket it classified as 'disk' and the + user was told to free space for a structurally damaged file (#77386 + comment thread, v0.20.0 malformed-DB incident).""" + import sqlite3 + + from hermes_state import classify_persistence_error + + assert classify_persistence_error( + sqlite3.DatabaseError("database disk image is malformed") + ) == "corrupt" + assert classify_persistence_error( + "database disk image is malformed" + ) == "corrupt" + assert classify_persistence_error( + sqlite3.DatabaseError("file is not a database") + ) == "corrupt" + assert classify_persistence_error("malformed database schema") == "corrupt" + # Genuine disk-space failures must keep classifying as 'disk'. + assert classify_persistence_error("database or disk is full") == "disk" + assert classify_persistence_error("disk I/O error") == "disk" + + def test_classify_persistence_error_reuses_disk_full_markers(): """The disk bucket delegates to hermes_state.is_disk_full_error, so every marker that helper recognizes (ENOSPC, 'not enough space', ...) @@ -266,6 +304,7 @@ def test_persistence_error_causes_tuple_matches_classifier(): "database is locked", "Session 'abc' is being compressed by another writer", "Session turn lease lost; refusing transcript write for 'abc'", + "database disk image is malformed", "database or disk is full", "something else entirely", None,