fix(state): distinguish retired WAL recovery guidance

Classify deleted WAL generations separately from main-file replacement and point operators at the captured-generation manifest and mode-aware recovery path.

Co-authored-by: crazyief <8566250+crazyief@users.noreply.github.com>
This commit is contained in:
gaoanze
2026-09-11 14:33:08 +08:00
committed by Teknium
parent 3a3ec6eeaf
commit 031d3760d0
4 changed files with 51 additions and 10 deletions
+15
View File
@@ -119,6 +119,21 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = {
"sessions/<session_id>.jsonl and, on the gateway, "
"pending_messages/pending-*.json."
),
"deleted_wal": (
"the turn was stopped because a live Hermes process held a retired "
"state.db-wal generation after its pathname was deleted or "
"replaced. Stop the gateway, dashboard, and cron writers; "
"do not overwrite the current state.db or delete its sidecars. "
"Check the logs for whether Hermes captured the retired generation, "
"then read the adjacent state.db.retired-wal-*/manifest.json. If "
"manifest.main.mode is `copied`, inspect that artifact with `hermes "
"sessions recover --source <state.db.retired-wal-*/state.db> "
"--inspect-only` before deciding whether its committed frames belong "
"on the current database. A `header_only` artifact is forensic and "
"does not contain a copied state.db to inspect. Unwritten messages "
"were diverted to sessions/<session_id>.jsonl and, on the gateway, "
"pending_messages/pending-*.json."
),
"corrupt": (
"the turn was stopped because the state database "
"reported structural corruption (the transcript would "
+10 -4
View File
@@ -74,8 +74,8 @@ def is_disk_full_error(exc: BaseException | str | None) -> bool:
# Every classify_persistence_error bucket; consumers enumerate this tuple.
PERSISTENCE_ERROR_CAUSES = (
"locked", "compression", "compression_closed", "turn_lease", "corrupt", "replaced", "disk",
"unknown",
"locked", "compression", "compression_closed", "turn_lease", "corrupt", "replaced",
"deleted_wal", "disk", "unknown",
)
@@ -182,6 +182,9 @@ _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"),
)
@@ -189,7 +192,9 @@ _PERSISTENCE_CAUSE_BY_PHRASE = (
(("turn lease",), "turn_lease"),
(("closed by compression",), "compression_closed"),
(("being compressed", "compression lease"), "compression"),
(("was replaced underneath", "deleted state.db-wal", "deleted state.db-shm"), "replaced"),
# 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"),
)
@@ -200,7 +205,8 @@ def classify_persistence_error(exc_or_str) -> str:
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); "replaced" = stop writing."""
file damage (repair path, not disk space); "replaced" = main-file replacement;
"deleted_wal" = a retired sidecar generation requiring capture inspection."""
if exc_or_str is None:
return "unknown"
# Lease refusals contain neither "locked" nor "busy": match by type first,
@@ -20,8 +20,8 @@ import hermes_state_dbfile
import hermes_state_readpool
import hermes_state_wal
from hermes_state import (
DeletedWalGenerationError, SessionDB, _close_time_checkpoint_configurable, classify_persistence_error,
refuse_deleted_wal_generation,
DeletedWalGenerationError, SessionDB, StateDbReplacedError, _close_time_checkpoint_configurable,
classify_persistence_error, refuse_deleted_wal_generation,
)
from hermes_state_dbfile import _pread_db_header, iter_deleted_sqlite_sidecar_holders
from tests.hermes_state._wal_generation_harness import (
@@ -35,13 +35,17 @@ def force_wal(monkeypatch):
pin_wal(monkeypatch)
def test_classify_deleted_wal_is_replaced_not_disk():
err = DeletedWalGenerationError(
def test_classify_deleted_wal_separately_from_main_file_replacement():
message = (
"FATAL: a live process holds a deleted state.db-wal or state.db-shm "
"inode while the path names a different (or missing) generation."
)
assert classify_persistence_error(err) == "replaced"
assert classify_persistence_error(str(err)) == "replaced"
assert classify_persistence_error(DeletedWalGenerationError(message)) == "deleted_wal"
assert classify_persistence_error(message) == "deleted_wal"
replaced = StateDbReplacedError("state.db was replaced underneath this process")
assert classify_persistence_error(replaced) == "replaced"
assert classify_persistence_error(str(replaced)) == "replaced"
def test_iter_holders_empty_on_non_linux(monkeypatch, tmp_path):
@@ -184,6 +184,21 @@ def test_explanation_persistence_replaced_cause_forbids_inplace_repair():
assert "full disk" not in lower
def test_deleted_wal_cause_is_enumerated_and_points_to_retired_capture():
from hermes_state_errors import PERSISTENCE_ERROR_CAUSES
out = AIAgent._format_turn_completion_explanation(
"session_persistence_failed", "deleted_wal"
).lower()
assert "deleted_wal" in PERSISTENCE_ERROR_CAUSES
assert "retired-wal-*/manifest.json" in out
assert "manifest.main.mode" in out
assert "sessions recover" in out and "--inspect-only" in out
assert "header_only" in out and "does not contain a copied state.db" in out
assert "check the logs for whether" in out
assert "restore the intended state.db" not in out
def test_explanation_persistence_unknown_cause_is_neutral():
"""None/'unknown' cause must not claim disk-full — point at diagnostics."""
for cause in (None, "unknown"):
@@ -336,6 +351,7 @@ def test_persistence_error_causes_tuple_matches_classifier():
"Session turn lease lost; refusing transcript write for 'abc'",
"database disk image is malformed",
"FATAL: state.db was replaced underneath the gateway",
"FATAL: a live process holds a deleted state.db-wal or state.db-shm inode.",
"database or disk is full",
"something else entirely",
None,