simplify(compat): hermes_state — drop 81 re-exports + 3 registry aliases + 3 shims, repoint 45 callers + 60 test files
hermes_state.py: delete every '# noqa: F401 (re-exported...)' import block (hermes_state_common/errors/guard/
readpool/sessions/fts/dbfile/wal/repair/registry + agent.context_compressor _DB_PERSISTED_MARKER_KEY); keep
only the names hermes_state.py itself uses, without noqa.
hermes_state_registry.py: drop get_shared_session_db/release_shared_session_db/close_shared_session_dbs
aliases; every caller (gateway/, tools/, tui_gateway/, cron/, mcp_serve, run_agent, tests) now imports
acquire/release/close_all/release_or_close from hermes_state_registry.
hermes_state_titles.py: drop set_auto_title_if_empty shim (title_generator keeps its getattr fallback).
Re-remove shim-only names restored by 34abf954bd: latest_user_message_row_id (tests call
latest_message_row_id(key, role='user'); role-targeting assertions kept) and get_session_activity (tests
build the snapshot via agent.session_activity.build_activity_snapshot over db.get_session(sid)).
hermes_state_wal._log_once resolves its dedupe sets as module globals instead of via hermes_state;
hermes_state_repair helpers call module globals directly (tests patch hermes_state_repair.<name>).
Frozen updater surface untouched (update_cmd_maint imports only SessionDB from hermes_state).
This commit is contained in:
@@ -82,7 +82,7 @@ def _surface(layer: str, code: str, retryable: bool, provider: str = "", model:
|
||||
|
||||
def _disk_full(candidate: Any) -> bool:
|
||||
try:
|
||||
from hermes_state import is_disk_full_error
|
||||
from hermes_state_errors import is_disk_full_error
|
||||
|
||||
return bool(is_disk_full_error(candidate))
|
||||
except Exception: # pragma: no cover - defensive import guard
|
||||
|
||||
@@ -242,10 +242,8 @@ def _db_flush_failed(agent, e: Exception, batch_rows: List[Dict[str, Any]], adop
|
||||
agent._db_flush_scan_prefix = None # full re-scan next flush: an exception mid-loop leaves mixed dispositions
|
||||
# The only place the SQLite error is visible before it becomes a bare False — classify it so the turn-end
|
||||
# explanation can distinguish lock contention from disk-full/read-only.
|
||||
from hermes_state import (
|
||||
CompressionSessionClosedError, StateDbCorruptError, StateDbReplacedError, classify_persistence_error,
|
||||
divert_session_transcript_jsonl,
|
||||
)
|
||||
from hermes_state import StateDbCorruptError, StateDbReplacedError, classify_persistence_error, divert_session_transcript_jsonl
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
agent._last_persistence_error_cause = classify_persistence_error(e)
|
||||
if isinstance(e, (StateDbReplacedError, StateDbCorruptError)):
|
||||
# A replaced/quarantined handle will not take this batch again — keep it on disk.
|
||||
|
||||
@@ -112,7 +112,7 @@ def _db_path() -> Path:
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
path = _db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -89,7 +89,7 @@ def _transaction() -> Iterator[sqlite3.Connection]:
|
||||
pass
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
apply_wal_with_fallback(conn, db_label="cron/deliveries.db")
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ def prepare_ledger(
|
||||
conn: sqlite3.Connection, *, db_label: str, synchronous_full: bool = True
|
||||
) -> None:
|
||||
"""Row factory + busy timeout + WAL (with fallback) + optional ``synchronous=FULL``."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
|
||||
+6
-6
@@ -60,7 +60,7 @@ def _close_late_session_db_result(future: "concurrent.futures.Future") -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
db = future.result()
|
||||
if db is not None:
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
release_or_close(db)
|
||||
|
||||
|
||||
@@ -1684,15 +1684,15 @@ def _open_cron_session_db(job: dict):
|
||||
# timeout proceeds without a session store instead of blocking the run forever.
|
||||
_session_db_timeout = _get_session_db_timeout()
|
||||
try:
|
||||
from hermes_state import get_shared_session_db
|
||||
from hermes_state_registry import acquire
|
||||
|
||||
if _session_db_timeout <= 0:
|
||||
return get_shared_session_db()
|
||||
return acquire()
|
||||
_session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
||||
# Copy the context so a profile run resolves ITS OWN home/state.db on the worker thread
|
||||
# instead of the process-global default.
|
||||
_session_db_context = contextvars.copy_context()
|
||||
_session_db_future = _session_db_pool.submit(_session_db_context.run, get_shared_session_db)
|
||||
_session_db_future = _session_db_pool.submit(_session_db_context.run, acquire)
|
||||
try:
|
||||
return _session_db_future.result(timeout=_session_db_timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
@@ -1884,7 +1884,7 @@ def _final_response_from_result(result: dict, job_id: str, job_name: str, AIAgen
|
||||
# Render every persistence-cause variant or cause-refined text slips through.
|
||||
_explainer_variants = []
|
||||
try:
|
||||
from hermes_state import PERSISTENCE_ERROR_CAUSES as _causes
|
||||
from hermes_state_errors import PERSISTENCE_ERROR_CAUSES as _causes
|
||||
except Exception:
|
||||
_causes = ("locked", "disk", "unknown")
|
||||
for _cause in (None, *_causes):
|
||||
@@ -1982,7 +1982,7 @@ def _finalize_cron_session(session_db, agent, job_id: str, job_name: str, cron_s
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Job '%s': failed to end session: %s", job_id, e)
|
||||
try:
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
release_or_close(_session_db)
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
|
||||
|
||||
@@ -342,8 +342,8 @@ def _entries_from_origins(platform_name: str, source: str, origins_fn) -> List[D
|
||||
def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
|
||||
"""Pull channels/contacts from state.db gateway session rows."""
|
||||
def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]:
|
||||
from hermes_state import get_shared_session_db, release_or_close
|
||||
db = get_shared_session_db()
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
db = acquire()
|
||||
try:
|
||||
lister = getattr(db, "list_gateway_sessions", None)
|
||||
if not callable(lister):
|
||||
|
||||
@@ -62,7 +62,7 @@ def _connect() -> sqlite3.Connection:
|
||||
|
||||
|
||||
def _initialize_schema(conn: sqlite3.Connection) -> None:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(conn, db_label="state.db (delivery_ledger)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS delivery_obligations (
|
||||
|
||||
@@ -106,7 +106,7 @@ class HostedRoomPolicyCheckpoint:
|
||||
conn.execute(ddl)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(self.db_path, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
@@ -113,7 +113,7 @@ def connect(
|
||||
from the journal-mode pragma is retried (it may ignore the busy timeout while another
|
||||
first opener initializes the DB, especially on Windows).
|
||||
"""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=10)
|
||||
|
||||
+4
-4
@@ -74,8 +74,8 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non
|
||||
for pre-migration databases.
|
||||
"""
|
||||
try:
|
||||
from hermes_state import get_shared_session_db, release_or_close
|
||||
db = get_shared_session_db()
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
db = acquire()
|
||||
try:
|
||||
finder = getattr(db, "find_session_by_origin", None)
|
||||
session_id = finder(platform=platform, chat_id=chat_id, thread_id=thread_id, user_id=user_id) if callable(finder) else None
|
||||
@@ -117,9 +117,9 @@ def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = Non
|
||||
def _append_to_sqlite(session_id: str, message: dict) -> None:
|
||||
"""Append a message to the SQLite session database."""
|
||||
try:
|
||||
from hermes_state import get_shared_session_db, release_or_close
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
|
||||
db = get_shared_session_db()
|
||||
db = acquire()
|
||||
try:
|
||||
db.append_message(session_id=session_id, role=message.get("role", "assistant"), content=message.get("content"))
|
||||
finally:
|
||||
|
||||
@@ -685,7 +685,7 @@ class ResponseStore:
|
||||
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
self._db_path = None
|
||||
# Shared WAL-fallback so response_store.db degrades gracefully on NFS/SMB/FUSE homes.
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(self._conn, db_label="response_store.db")
|
||||
self._conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS responses ("
|
||||
|
||||
@@ -80,7 +80,7 @@ class RunIdempotencyStore:
|
||||
"process memory, so replay will not survive a restart: %s", exc)
|
||||
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||
self._db_path = None
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(self._conn, db_label="runs_idempotency.db")
|
||||
self._conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS run_idempotency (
|
||||
|
||||
+6
-5
@@ -3629,7 +3629,8 @@ class GatewayRunner(
|
||||
after recording that recoverable state so ``__init__`` can record ``_session_db_init_error`` for the
|
||||
#88235 broadcast.
|
||||
"""
|
||||
from hermes_state import AsyncSessionDB, _default_db_path, get_shared_session_db
|
||||
from hermes_state import AsyncSessionDB, _default_db_path
|
||||
from hermes_state_registry import acquire
|
||||
from gateway.session_db_recovery import RecoverableHandleCache
|
||||
path = Path(_default_db_path())
|
||||
cache = getattr(self, "_session_db_handle_cache", None)
|
||||
@@ -3659,7 +3660,7 @@ class GatewayRunner(
|
||||
# Store handle unavailable: opening our own would resurrect the duplicate borrowed away.
|
||||
raise RuntimeError("SessionStore SQLite handle unavailable")
|
||||
try:
|
||||
return AsyncSessionDB(get_shared_session_db())
|
||||
return AsyncSessionDB(acquire())
|
||||
except Exception as exc:
|
||||
logger.warning("SQLite session store not available: %s", exc)
|
||||
raise
|
||||
@@ -3698,7 +3699,7 @@ class GatewayRunner(
|
||||
return
|
||||
# Shared instances no-op on close() (the registry owns the lifecycle). Release the refcount
|
||||
# instead (#90837).
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
try:
|
||||
release_or_close(inner)
|
||||
except Exception as exc:
|
||||
@@ -4447,10 +4448,10 @@ def _housekeeping_auto_archive() -> None:
|
||||
"""Stale-session auto-archive on a live timer (the startup hook fires once); maybe_auto_archive()
|
||||
is gated by sessions.min_interval_hours. Opens its own SessionDB — SQLite connections are thread-bound."""
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
from hermes_state import get_shared_session_db, release_or_close
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
_sess_cfg = (_load_full_config().get("sessions") or {})
|
||||
if _sess_cfg.get("auto_archive", False):
|
||||
_adb = get_shared_session_db()
|
||||
_adb = acquire()
|
||||
try:
|
||||
_adb.maybe_auto_archive(
|
||||
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
|
||||
|
||||
@@ -1656,8 +1656,8 @@ class GatewayShutdownMixin:
|
||||
# Shared SessionDB instances still held by the process-wide registry (tools, cron, mirror).
|
||||
# This is the safety net that guarantees no WAL write lock survives past gateway shutdown
|
||||
# (#90837).
|
||||
from hermes_state import close_shared_session_dbs
|
||||
closed = close_shared_session_dbs()
|
||||
from hermes_state_registry import close_all
|
||||
closed = close_all()
|
||||
if closed:
|
||||
logger.debug("Closed %d shared SessionDB instance(s) at shutdown", closed)
|
||||
|
||||
|
||||
@@ -51,13 +51,14 @@ class SessionPersistenceMixin:
|
||||
Resolving here rather than once in ``__init__`` is the whole fix for #88532: it lets the scoping
|
||||
that the multiplexed inbound path already performs actually reach session storage.
|
||||
"""
|
||||
from hermes_state import _default_db_path, get_shared_session_db
|
||||
from hermes_state import _default_db_path
|
||||
from hermes_state_registry import acquire
|
||||
|
||||
path = Path(db_path) if db_path is not None else Path(_default_db_path())
|
||||
|
||||
def _open():
|
||||
try:
|
||||
return get_shared_session_db(path) # process-wide registry: one writer per path
|
||||
return acquire(path) # process-wide registry: one writer per path
|
||||
except Exception as e:
|
||||
if not _is_live_system_guard(e):
|
||||
print(f"[gateway] Warning: SQLite session store unavailable, falling back to JSONL: {e}")
|
||||
@@ -195,7 +196,7 @@ class SessionPersistenceMixin:
|
||||
would strand secondary profiles' handles with their WAL lock held ('database is locked' on
|
||||
restart). Drained under the lock, closed outside it; a pinned handle is the pinner's."""
|
||||
def _close(db) -> None:
|
||||
from hermes_state import release_or_close # shared instances no-op on close()
|
||||
from hermes_state_registry import release_or_close # shared instances no-op on close()
|
||||
try:
|
||||
release_or_close(db)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -243,9 +243,8 @@ class SessionTranscriptMixin:
|
||||
try:
|
||||
self._append_transcript_message(session_id, msg)
|
||||
except Exception as exc:
|
||||
from hermes_state import (
|
||||
CompressionSessionClosedError, StateDbCorruptError, StateDbReplacedError,
|
||||
)
|
||||
from hermes_state import StateDbCorruptError, StateDbReplacedError
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
if isinstance(exc, (StateDbReplacedError, StateDbCorruptError)):
|
||||
self._divert_transcript_after_db_replaced(session_id, queue_session_id, exc)
|
||||
return
|
||||
|
||||
@@ -209,8 +209,8 @@ def recover_pending_to_db(session_db=None) -> int:
|
||||
return 0
|
||||
own_db = session_db is None
|
||||
if own_db:
|
||||
from hermes_state import get_shared_session_db
|
||||
session_db = get_shared_session_db()
|
||||
from hermes_state_registry import acquire
|
||||
session_db = acquire()
|
||||
recovered = 0
|
||||
try:
|
||||
for path in flush_files:
|
||||
@@ -224,7 +224,7 @@ def recover_pending_to_db(session_db=None) -> int:
|
||||
finally:
|
||||
if own_db: # shutdown cancellation/interrupt must not strand an owned DB
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
release_or_close(session_db)
|
||||
if recovered:
|
||||
logger.info("Recovered %d pending message(s) from shutdown flush", recovered)
|
||||
|
||||
@@ -612,16 +612,16 @@ class GatewayStatusCommandsMixin:
|
||||
days = int(flag) if flag.isdigit() else days
|
||||
i += 1
|
||||
try:
|
||||
from hermes_state import get_shared_session_db
|
||||
from hermes_state_registry import acquire
|
||||
from agent.insights import InsightsEngine
|
||||
|
||||
def _run_insights():
|
||||
db = get_shared_session_db()
|
||||
db = acquire()
|
||||
try:
|
||||
engine = InsightsEngine(db)
|
||||
return engine.format_gateway(engine.generate(days=days, source=source))
|
||||
finally:
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
release_or_close(db)
|
||||
|
||||
# Not a bare hop: ``SessionDB()`` resolves ``get_hermes_home()`` at call time, a
|
||||
|
||||
@@ -709,7 +709,8 @@ def _sessions_repair(_engine: HermesConsoleEngine, args: list[str]) -> None:
|
||||
ns = _parse(
|
||||
"sessions repair", args, (("--check-only",), dict(action="store_true")),
|
||||
(("--no-backup",), dict(action="store_true")))
|
||||
from hermes_state import DEFAULT_DB_PATH, _db_opens_cleanly, repair_state_db_schema
|
||||
from hermes_state import DEFAULT_DB_PATH
|
||||
from hermes_state_repair import _db_opens_cleanly, repair_state_db_schema
|
||||
db_path = DEFAULT_DB_PATH
|
||||
if not db_path.exists():
|
||||
print(f"No session database at {db_path} (nothing to repair).")
|
||||
|
||||
@@ -171,7 +171,7 @@ def _repair_state_db(f: Finding, should_fix: bool, state_db_path: Path, kind: st
|
||||
ok_label, not_fixed_label, failed_issue, fix_hint = _STATE_DB_REPAIRS[kind]
|
||||
if not should_fix:
|
||||
return f.issues.append(fix_hint)
|
||||
from hermes_state import repair_state_db_schema
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
report = repair_state_db_schema(state_db_path)
|
||||
if not report.get("repaired"):
|
||||
check_warn(not_fixed_label, f"({report.get('error')}; backup: {report.get('backup_path')})")
|
||||
@@ -192,7 +192,7 @@ def _state_db_health(f: Finding, should_fix: bool, state_db_path: Path, _DHH: st
|
||||
check_ok(f"{_DHH}/state.db exists ({_session_count(state_db_path)} sessions)")
|
||||
# COUNT(*) succeeds even when the FTS index is corrupt and every write fails through the triggers;
|
||||
# _db_opens_cleanly drives a rolled-back write to surface that.
|
||||
from hermes_state import _db_opens_cleanly
|
||||
from hermes_state_repair import _db_opens_cleanly
|
||||
# `_db_opens_cleanly` now drives a rolled-back write so this otherwise-silent corruption class is
|
||||
# surfaced (and repaired in place with --fix). See #50502.
|
||||
_write_reason = _db_opens_cleanly(state_db_path)
|
||||
@@ -213,7 +213,7 @@ def _state_db_stats(issues: list, state_db_path: Path) -> None:
|
||||
"""Health/stats snapshot: strictly read-only (mode=ro) so it is safe against a live DB held by
|
||||
the gateway; any failure degrades to one info line rather than failing doctor."""
|
||||
with warn_on_error("state.db stats unavailable ({e})", "", report=lambda t, _d: check_info(t)):
|
||||
from hermes_state import collect_state_db_stats, count_db_holders
|
||||
from hermes_state_dbfile import collect_state_db_stats, count_db_holders
|
||||
rows = _render_state_db_stats(collect_state_db_stats(state_db_path), holders=count_db_holders(state_db_path))
|
||||
for _kind, _text, _detail in rows:
|
||||
if _kind != "warn":
|
||||
|
||||
@@ -231,12 +231,12 @@ def _maybe_checkpoint_wal(conn: sqlite3.Connection, db_path: Path) -> None:
|
||||
key = str(db_path)
|
||||
now = time.monotonic()
|
||||
with _WAL_CHECKPOINT_LOCK:
|
||||
last = _kb._LAST_WAL_CHECKPOINT.get(key)
|
||||
last = _LAST_WAL_CHECKPOINT.get(key)
|
||||
if last is not None and (now - last) < _WAL_CHECKPOINT_INTERVAL_SECONDS:
|
||||
return
|
||||
# Claim the slot first so concurrent same-process ticks don't
|
||||
# double-checkpoint on the boundary.
|
||||
_kb._LAST_WAL_CHECKPOINT[key] = now
|
||||
_LAST_WAL_CHECKPOINT[key] = now
|
||||
try:
|
||||
row = conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
|
||||
_kb._log.debug(
|
||||
@@ -582,7 +582,7 @@ def repair_db(db_path: Optional[Path] = None, *, board: Optional[str] = None) ->
|
||||
if _missing_or_empty(resolved):
|
||||
return RepairResult(status="missing", db_path=resolved)
|
||||
|
||||
with _kb._cross_process_init_lock(resolved):
|
||||
with _cross_process_init_lock(resolved):
|
||||
messages, reason = _probe_for_corruption(resolved)
|
||||
if messages is None:
|
||||
# Same quarantine the connect-time guard takes when sqlite
|
||||
@@ -641,8 +641,8 @@ def _open_configured(path: Path, under_lock) -> tuple[sqlite3.Connection, Any]:
|
||||
conn.row_factory = sqlite3.Row
|
||||
with _INIT_LOCK:
|
||||
# WAL doesn't work on network filesystems; the helper falls back to
|
||||
# DELETE with one ERROR log (see hermes_state._WAL_INCOMPAT_MARKERS).
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
# DELETE with one ERROR log (see hermes_state_wal._WAL_INCOMPAT_MARKERS).
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
|
||||
# FULL (not NORMAL): fsync before each checkpoint to narrow the
|
||||
# crash window that can leave a b-tree page header torn.
|
||||
@@ -699,7 +699,7 @@ def connect(db_path: Optional[Path] = None, *, board: Optional[str] = None) -> s
|
||||
path,
|
||||
)
|
||||
|
||||
with _kb._cross_process_init_lock(path):
|
||||
with _cross_process_init_lock(path):
|
||||
# Read-only file/sidecar preflight first, so a stray read-only kanban.db
|
||||
# fails actionably instead of "attempt to write a readonly database".
|
||||
# See #12508.
|
||||
@@ -1187,5 +1187,5 @@ def write_txn(conn: sqlite3.Connection, *, allow_nested: bool = False):
|
||||
|
||||
|
||||
# Late-bound origin namespace (see module docstring); imported LAST so this
|
||||
# module is fully populated before ``kanban_db`` re-exports from it.
|
||||
# module is fully populated before ``kanban_db`` imports from it.
|
||||
from hermes_cli import kanban_db as _kb # noqa: E402
|
||||
|
||||
@@ -123,7 +123,7 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
apply_wal_with_fallback(conn, db_label="projects.db")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
@@ -16,7 +16,9 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from hermes_state import (FTS_STORAGE_VERSION, SCHEMA_VERSION, SessionDB, _db_opens_cleanly)
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_STORAGE_VERSION, SCHEMA_VERSION
|
||||
from hermes_state_repair import _db_opens_cleanly
|
||||
|
||||
|
||||
ProgressCallback = Callable[[dict[str, Any]], None]
|
||||
@@ -180,7 +182,7 @@ def _copy_source_bundle(source: Path, snapshot_dir: Path) -> tuple[Path, list[st
|
||||
|
||||
The whole copy runs inside ``offline_file_access`` (holds the connection-lifecycle lock). Recovery
|
||||
normally runs as its own CLI process against an offline file, so the refusal should never fire; the
|
||||
guard keeps this path consistent with ``hermes_state._backup_db_file``.
|
||||
guard keeps this path consistent with ``hermes_state_repair._backup_db_file``.
|
||||
|
||||
Checking for a live connection and *then* copying would be a check/use race: a connection could open in
|
||||
that window, and the copy's ``close()`` would cancel its POSIX advisory locks -- the failure class
|
||||
|
||||
@@ -21,7 +21,8 @@ from hermes_cli.web_deps import late
|
||||
from hermes_cli.web_models import (
|
||||
BulkDeleteSessions, SessionImport, SessionOwnerBackfill, SessionPrune, SessionRename)
|
||||
from hermes_cli.web_routers._common import log as _log, http_failure
|
||||
from hermes_state import is_malformed_db_error, is_transient_sqlite_error
|
||||
from hermes_state import is_malformed_db_error
|
||||
from hermes_state_errors import is_transient_sqlite_error
|
||||
|
||||
list_router = APIRouter()
|
||||
search_router = APIRouter()
|
||||
|
||||
+26
-71
@@ -24,73 +24,35 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from agent.message_sanitization import _sanitize_surrogates
|
||||
# Known-durable message marker (run_agent keeps a copy: circular import; a test pins them in sync).
|
||||
from agent.context_compressor import ( # noqa: F401 (re-exported; tests import it from here)
|
||||
# Intrinsic persistence marker stamped on message dicts that are known-durable (#92231). One shared
|
||||
# constant with agent.context_compressor (this module already imports agent.* at module level, and
|
||||
# context_compressor is a transitive dependency via hermes_state_common). run_agent keeps its own
|
||||
# predating copy — hermes_state cannot import run_agent (circular) — guarded by
|
||||
# test_marker_constant_in_sync.
|
||||
_DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY,
|
||||
)
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
|
||||
|
||||
from hermes_state_common import ( # noqa: F401 (re-exported; tests import from hermes_state)
|
||||
AUTO_VACUUM_MIN_FREELIST_RATIO, _FTS_TRIGGERS, escape_like as _escape_like, FTS_CJK_STALE_KEY,
|
||||
FTS_REBUILD_DEFERRAL_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TRIGRAM_SQL, LEGACY_FTS_SQL,
|
||||
LEGACY_FTS_TRIGRAM_SQL, SCHEMA_SQL, SCHEMA_VERSION, stat_db_file_identity as _stat_db_file_identity,
|
||||
from hermes_state_common import escape_like as _escape_like, stat_db_file_identity as _stat_db_file_identity
|
||||
from hermes_state_errors import (
|
||||
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY,
|
||||
_STATE_DB_REPLACED_MSG, DeletedWalGenerationError, SessionCompressionInProgressError, StateDbCorruptError,
|
||||
StateDbReplacedError, _is_no_more_rows, classify_persistence_error, is_malformed_db_error,
|
||||
is_malformed_schema_error,
|
||||
)
|
||||
from hermes_state_errors import ( # noqa: F401 (re-exported; the historical import path)
|
||||
_DELETED_WAL_GENERATION_MSG, _DISK_IO_ERROR_MARKER, _STATE_DB_APPLICATION_ID_OFFSET,
|
||||
_STATE_DB_CORRUPT_MSG, _STATE_DB_GENERATION_KEY, _STATE_DB_REPLACED_MSG, PERSISTENCE_ERROR_CAUSES,
|
||||
CompressionSessionBusyError, CompressionSessionClosedError, DeletedWalGenerationError,
|
||||
SessionCompressionInProgressError, SessionTurnLeaseLostError, StateDbCorruptError,
|
||||
StateDbReplacedError, _is_no_more_rows, classify_persistence_error, is_disk_full_error,
|
||||
is_malformed_db_error, is_malformed_schema_error, is_transient_sqlite_error,
|
||||
from hermes_state_guard import (
|
||||
_STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _real_platform_state_root,
|
||||
_set_last_init_error, get_last_init_error,
|
||||
)
|
||||
from hermes_state_guard import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
|
||||
_STATE_DB_GUARD_BYPASS_ENV, _in_test_context, _is_production_state_db, _process_looks_like_pytest,
|
||||
_real_platform_state_root, _running_under_pytest, _set_last_init_error, get_last_init_error,
|
||||
)
|
||||
from hermes_state_readpool import ( # noqa: F401 (re-exported; tests import from hermes_state)
|
||||
_READ_POOL_MAX, _proc_fd_targets, _process_read_permits, _read_budget_for,
|
||||
)
|
||||
from hermes_state_sessions import ( # noqa: F401 (re-exported; the historical import path)
|
||||
SessionSessionsMixin, _cwd_prefix_clause, workspace_key,
|
||||
)
|
||||
from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension # noqa: F401 (re-exported)
|
||||
from hermes_state_readpool import _READ_POOL_MAX, _proc_fd_targets, _read_budget_for
|
||||
from hermes_state_sessions import SessionSessionsMixin
|
||||
from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension
|
||||
from hermes_state_portability import SessionPortabilityMixin
|
||||
from hermes_state_telegram import SessionTelegramTopicsMixin
|
||||
from hermes_state_schema import SessionSchemaMixin
|
||||
import hermes_state_holders as _state_holders
|
||||
from hermes_state_dbfile import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
|
||||
_canonical_sqlite_path, _concrete_state_db_holder_pids, _connect_tracked_db,
|
||||
_is_inactive_orphan_desktop_holder, _read_sqlite_application_id, _stat_sqlite_sidecar_identity, _watched_sqlite_sidecar_paths,
|
||||
collect_state_db_stats, count_db_holders, is_zeroed_state_db, iter_deleted_sqlite_sidecar_holders,
|
||||
quarantine_cross_process_lock, quarantine_zeroed_state_db, refuse_deleted_wal_generation,
|
||||
from hermes_state_dbfile import (
|
||||
_canonical_sqlite_path, _connect_tracked_db, _read_sqlite_application_id, _stat_sqlite_sidecar_identity,
|
||||
_watched_sqlite_sidecar_paths, is_zeroed_state_db, quarantine_cross_process_lock, quarantine_zeroed_state_db,
|
||||
refuse_deleted_wal_generation,
|
||||
)
|
||||
from hermes_state_messages import SessionMessagesMixin
|
||||
from hermes_state_wal import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
|
||||
WalUnsupportedError, _WAL_INCOMPAT_MARKERS, _apply_macos_checkpoint_barrier, _apply_synchronous_pragma,
|
||||
_database_has_content, _delete_overridden_warned_paths, _enforce_macos_synchronous_full,
|
||||
_journal_upgrade_warned_paths, _on_disk_journal_mode, _wal_fallback_warned_paths,
|
||||
_wal_reset_bug_warned_paths, _wal_reset_repair_hint, apply_database_pragmas, apply_wal_with_fallback,
|
||||
is_sqlite_wal_reset_vulnerable, resolve_journal_mode, resolve_synchronous_level, sqlite_source_id,
|
||||
)
|
||||
from hermes_state_repair import ( # noqa: F401 (re-exported; tests patch hermes_state.<name>)
|
||||
_MAX_MALFORMED_BACKUPS, _MAX_PERSISTENT_REPAIR_ATTEMPTS, _REPAIR_BACKUP_MIN_FREE_BYTES,
|
||||
_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND, _backup_content_identity, _backup_db_file,
|
||||
_claim_repair_attempt, _connect_repair_durable, _copy_database_snapshot, _cross_process_repair_lock,
|
||||
_db_fingerprint, _db_opens_cleanly, _existing_malformed_backups, _live_writer_holds_db,
|
||||
_persistent_repair_attempts_exhausted, _probe_journal_mode_for_repair, _prune_malformed_backups,
|
||||
_read_repair_ledger, _record_repair_outcome, _release_auto_maintenance_lock,
|
||||
_repair_backup_headroom_bytes, _repair_ledger_path, _repair_scratch_space_error,
|
||||
_repair_snapshot_timeout_seconds, _repair_state_db_schema_locked, _restore_journal_mode_after_repair,
|
||||
_exclusive_repair_db_guard, _run_repair_strategies,
|
||||
_try_acquire_auto_maintenance_lock, _unlink_db_triple, apply_durability_barriers,
|
||||
preflight_db_writability, repair_state_db_schema,
|
||||
)
|
||||
from hermes_state_wal import _WAL_INCOMPAT_MARKERS, apply_database_pragmas, apply_wal_with_fallback
|
||||
from hermes_state_repair import _claim_repair_attempt, preflight_db_writability, repair_state_db_schema
|
||||
from hermes_state_titles import SessionTitlesMixin
|
||||
from hermes_state_usage import SessionUsageMixin
|
||||
from hermes_state_maintenance import SessionMaintenanceMixin
|
||||
@@ -350,23 +312,16 @@ def divert_session_transcript_jsonl(session_id: str, messages) -> "Optional[Path
|
||||
|
||||
|
||||
# Process-wide shared SessionDB registry: long-lived in-process callers share ONE writer
|
||||
# connection per resolved path via get_shared_session_db(); one-shots use SessionDB() + close().
|
||||
# connection per resolved path via hermes_state_registry.acquire(); one-shots use SessionDB() + close().
|
||||
def _foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
|
||||
"""Compatibility delegate to the state-holder authority."""
|
||||
return _state_holders.foreign_state_db_holders(db_path)
|
||||
|
||||
|
||||
# ── Process-wide shared SessionDB registry (#90837) ── The registry itself lives in
|
||||
# hermes_state_registry.py — a bounded module owning acquisition, generation identity, refcounting,
|
||||
# retirement, and teardown. These re-exports keep the historical import path (``from hermes_state import
|
||||
# get_shared_session_db``) working for every call site and test that imports from here. Routing rules (see
|
||||
# hermes_state_registry for the full lifecycle): - Long-lived in-process callers (gateway, tui_gateway,
|
||||
# cron, in-process tools) share ONE writer connection per resolved path via get_shared_session_db(). - CLI
|
||||
# one-shots, recovery flows, and read-only cross-profile opens keep using SessionDB() directly with their
|
||||
# own close().
|
||||
from hermes_state_registry import ( # noqa: F401 (re-export)
|
||||
close_shared_session_dbs, get_shared_session_db, release_or_close,
|
||||
)
|
||||
# ── Process-wide shared SessionDB registry (#90837) ── lives in hermes_state_registry.py (acquire /
|
||||
# release / close_all / release_or_close). Long-lived in-process callers (gateway, tui_gateway, cron,
|
||||
# in-process tools) share ONE writer connection per resolved path via hermes_state_registry.acquire(); CLI
|
||||
# one-shots, recovery flows, and read-only cross-profile opens use SessionDB() directly with their own close().
|
||||
|
||||
|
||||
class SessionDB(
|
||||
@@ -509,8 +464,8 @@ class SessionDB(
|
||||
self._token_writer_thread: Optional[threading.Thread] = None
|
||||
self._token_writer_stop = self._token_writer_busy = False
|
||||
self._token_atexit_hook: Optional[Callable[[], None]] = None
|
||||
# Opened via get_shared_session_db(): close() releases a refcount instead.
|
||||
# Set True when this instance is opened via get_shared_session_db(). Makes close() a no-op so the
|
||||
# Opened via hermes_state_registry.acquire(): close() releases a refcount instead.
|
||||
# Set True when this instance is opened via hermes_state_registry.acquire(). Makes close() a no-op so the
|
||||
# registry (not individual callers) controls the connection lifecycle (#90837).
|
||||
self._shared_registry_owned = False
|
||||
initialization_complete = False
|
||||
@@ -1174,7 +1129,7 @@ class SessionDB(
|
||||
|
||||
Drains queued token deltas first (the background writer needs the connection). Read-only connections
|
||||
never request a checkpoint. See #45383.
|
||||
When this instance is shared (opened via ``get_shared_session_db``), ``close()`` RELEASES one
|
||||
When this instance is shared (opened via ``hermes_state_registry.acquire``), ``close()`` RELEASES one
|
||||
refcount instead of tearing down the connection: the registry owns the lifecycle and only closes on
|
||||
the final release (#90837). This prevents one caller's close from tearing down the writer connection
|
||||
that other callers in the same process are still using — while still letting legacy ``close()`` call
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Shared constants and helpers for the SessionDB family of modules. Lives outside hermes_state so
|
||||
the mixin modules can import it without a cycle; hermes_state re-exports every name."""
|
||||
the mixin modules can import it without a cycle."""
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
@@ -817,7 +817,7 @@ END;
|
||||
# structural rebuild (FTS5 'rebuild' or `_recover_stale_fts`'s drop/recreate) must run in ONE at a time —
|
||||
# concurrent rebuilds corrupted state.db in production. Gates `rebuild_fts()`, `_rebuild_fts_indexes()`,
|
||||
# `_recover_stale_fts()`; the chunked backfill (`fts_rebuild_step`) is deliberately NOT routed through it (it
|
||||
# claims progress under SQLite transaction authority). Mirrors `hermes_state._cross_process_repair_lock`:
|
||||
# claims progress under SQLite transaction authority). Mirrors `hermes_state_repair._cross_process_repair_lock`:
|
||||
# portable (msvcrt/flock), bounded wait, FAIL CLOSED; orphaned-fd holders (see `_acquire_db_flock`) are broken
|
||||
# only when provably dead, indeterminate liveness defers. `<db>.fts_rebuild.lock` is distinct from
|
||||
# `<db>.repair.lock` (offline schema surgery, minutes in VACUUM). Lives here: mixins cannot import hermes_state.
|
||||
@@ -832,7 +832,7 @@ END;
|
||||
# `SessionSchemaMixin._rebuild_fts_indexes()` (via `_init_schema`), and
|
||||
# `SessionSchemaMixin._recover_stale_fts()`. The chunked deferred backfill (`fts_rebuild_step`) is
|
||||
# deliberately NOT routed through it — it claims progress under `_execute_write`'s SQLite transaction
|
||||
# authority and is intentionally multi-process. Semantics mirror `hermes_state._cross_process_repair_lock`
|
||||
# authority and is intentionally multi-process. Semantics mirror `hermes_state_repair._cross_process_repair_lock`
|
||||
# (the schema- surgery authority): portable (msvcrt on Windows, flock elsewhere), bounded wait, and FAIL
|
||||
# CLOSED — a caller that cannot acquire the lock must NOT rebuild. The kernel drops both lock types when the
|
||||
# holder dies — UNLESS a forked child inherited the lock fd (flock rides the open file description, which
|
||||
|
||||
@@ -198,7 +198,7 @@ class SessionCompressionMixin:
|
||||
See #75316.
|
||||
``None`` = unbounded (no internal flush happened). See #47202.
|
||||
"""
|
||||
from hermes_state import CompressionSessionBusyError
|
||||
from hermes_state_errors import CompressionSessionBusyError
|
||||
def _do(conn):
|
||||
if require_lease_refresh and compression_lock_holder:
|
||||
conn.execute(
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
Header probes (application_id / zeroed-file detection), deleted-WAL-sidecar
|
||||
holder scans, quarantine of zeroed databases, ``collect_state_db_stats`` and
|
||||
holder-process classification. Every name is re-imported into ``hermes_state``
|
||||
so ``hermes_state.<name>`` keeps resolving — and tests that monkeypatch it keep
|
||||
intercepting, because intra-module calls to patched helpers go through a lazy
|
||||
``from hermes_state import ...`` at call time.
|
||||
holder-process classification. Helpers that hermes_state itself imports and
|
||||
calls (``_connect_tracked_db`` & co) are looked up lazily from ``hermes_state`` at
|
||||
call time, so tests that monkeypatch ``hermes_state.<name>`` keep intercepting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,7 +80,7 @@ def _pread_db_header(db_path: Path, length: int) -> "Optional[bytes]":
|
||||
|
||||
def _read_sqlite_application_id(db_path: Path) -> "Optional[int]":
|
||||
"""application_id from the SQLite header, via the lock-safe :func:`_pread_db_header`."""
|
||||
from hermes_state import _STATE_DB_APPLICATION_ID_OFFSET
|
||||
from hermes_state_errors import _STATE_DB_APPLICATION_ID_OFFSET
|
||||
end = _STATE_DB_APPLICATION_ID_OFFSET + 4
|
||||
header = _pread_db_header(db_path, end)
|
||||
if header is None or len(header) < end or header[:16] != b"SQLite format 3\x00":
|
||||
@@ -142,7 +141,8 @@ def iter_deleted_sqlite_sidecar_holders(db_path) -> List[Tuple[int, str]]:
|
||||
def refuse_deleted_wal_generation(db_path) -> None:
|
||||
"""Raise if any process holds a deleted WAL/SHM generation for *db_path*; called
|
||||
*before* ``sqlite3.connect`` so a second opener cannot mint a replacement WAL inode."""
|
||||
from hermes_state import DeletedWalGenerationError, _DELETED_WAL_GENERATION_MSG
|
||||
from hermes_state import DeletedWalGenerationError
|
||||
from hermes_state_errors import _DELETED_WAL_GENERATION_MSG
|
||||
if not iter_deleted_sqlite_sidecar_holders(db_path):
|
||||
return
|
||||
logger.error(_DELETED_WAL_GENERATION_MSG)
|
||||
|
||||
@@ -147,7 +147,8 @@ class SessionGatewayMixin:
|
||||
|
||||
Fails closed: anything whose parent, age, argv, or network connections
|
||||
cannot be proved safe remains a repair-blocking holder."""
|
||||
from hermes_state import _concrete_state_db_holder_pids, _is_inactive_orphan_desktop_holder, psutil
|
||||
from hermes_state import psutil
|
||||
from hermes_state_dbfile import _concrete_state_db_holder_pids, _is_inactive_orphan_desktop_holder
|
||||
if not sys.platform.startswith("linux") or psutil is None:
|
||||
return []
|
||||
try:
|
||||
|
||||
@@ -28,7 +28,7 @@ def _like(value: str) -> str:
|
||||
|
||||
|
||||
def _cwd_prefix_filter(value: str) -> Tuple[List[str], list]:
|
||||
from hermes_state import _cwd_prefix_clause
|
||||
from hermes_state_sessions import _cwd_prefix_clause
|
||||
clause, params = _cwd_prefix_clause(value)
|
||||
return [clause], list(params)
|
||||
|
||||
@@ -107,7 +107,8 @@ class SessionMaintenanceMixin:
|
||||
def _write_guards_reject(self, conn, sid: str, **kwargs) -> bool:
|
||||
"""True when a live turn lease / compression lock protects ``sid``; expired or
|
||||
dead-holder guards are reclaimed and fenced as a side effect."""
|
||||
from hermes_state import SessionCompressionInProgressError, SessionTurnLeaseLostError
|
||||
from hermes_state import SessionCompressionInProgressError
|
||||
from hermes_state_errors import SessionTurnLeaseLostError
|
||||
try:
|
||||
self._check_transcript_write_guards(
|
||||
conn, sid, compression_lock_holder=None, turn_lease_holder=None,
|
||||
@@ -376,7 +377,7 @@ class SessionMaintenanceMixin:
|
||||
``request_dump_*``) for pruned sessions are removed as part of the same sweep (issue #3015).
|
||||
Messaging and UI sources are never touched here. See #54189.
|
||||
"""
|
||||
from hermes_state import _release_auto_maintenance_lock, _try_acquire_auto_maintenance_lock
|
||||
from hermes_state_repair import _release_auto_maintenance_lock, _try_acquire_auto_maintenance_lock
|
||||
result: Dict[str, Any] = {"skipped": False, "pruned": 0, "closed": 0, "vacuumed": False}
|
||||
maintenance_lock = _try_acquire_auto_maintenance_lock(self.db_path)
|
||||
if maintenance_lock is None:
|
||||
|
||||
@@ -189,7 +189,8 @@ class SessionMessagesMixin:
|
||||
#74478 patience note below). User-initiated transcript mutations may opt in to rejecting an active
|
||||
unowned turn lease in that same transaction.
|
||||
"""
|
||||
from hermes_state import CompressionSessionClosedError, SessionCompressionInProgressError, SessionTurnLeaseLostError
|
||||
from hermes_state import SessionCompressionInProgressError
|
||||
from hermes_state_errors import CompressionSessionClosedError, SessionTurnLeaseLostError
|
||||
# NOTE (#75316 redesign): appends do NOT check compression_locks. The lock's job is to stop two
|
||||
# COMPRESSIONS colliding, not to fence ordinary transcript writes. Concurrent appends during a
|
||||
# compression are safe by construction: archive_and_compact() commits against a watermark captured
|
||||
@@ -410,15 +411,6 @@ class SessionMessagesMixin:
|
||||
(session_id, role, int(offset)))
|
||||
return row[0] if row else None
|
||||
|
||||
def latest_user_message_row_id(self, session_id: str) -> Optional[int]:
|
||||
"""Row id of the most recent active user message, or ``None``.
|
||||
|
||||
The agent's default reaction target: "the message that triggered me",
|
||||
so the model never has to thread row ids through a tool call (mirrors
|
||||
the photon adapter's ``_record_last_inbound``).
|
||||
"""
|
||||
return self.latest_message_row_id(session_id, role="user")
|
||||
|
||||
def get_message_role(self, session_id: str, row_id: int) -> Optional[str]:
|
||||
"""Role of the active message at *row_id* in *session_id*, or ``None``."""
|
||||
if not session_id:
|
||||
@@ -461,7 +453,7 @@ class SessionMessagesMixin:
|
||||
is inserted as fresh active rows exactly as in the destructive path, so the live view is identical
|
||||
either way; only the durability of the dropped turns differs.
|
||||
"""
|
||||
from hermes_state import CompressionSessionClosedError
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
def _do(conn):
|
||||
if reject_active_turn_lease:
|
||||
self._check_transcript_write_guards(
|
||||
|
||||
@@ -214,12 +214,6 @@ def stats() -> Dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
# Backwards-compatible aliases (hermes_state re-exports them).
|
||||
get_shared_session_db = acquire
|
||||
release_shared_session_db = release
|
||||
close_shared_session_dbs = close_all
|
||||
|
||||
|
||||
def release_or_close(db: "SessionDB") -> None:
|
||||
"""Release a shared instance, or close it when it is not registry-managed. Drop-in for a
|
||||
plain ``db.close()``: read-only opens, CLI one-shots and test fakes fall back."""
|
||||
|
||||
+11
-22
@@ -1,7 +1,6 @@
|
||||
"""state.db repair, backup and writability preflight (split from hermes_state).
|
||||
|
||||
Every name is re-imported into ``hermes_state``; intra-module calls to patchable helpers go through a lazy
|
||||
``from hermes_state import ...`` at call time so monkeypatches there still intercept.
|
||||
Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_repair.<name>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -291,7 +290,7 @@ def _backup_free_space_error(db_path: Path) -> Optional[str]:
|
||||
def _repair_snapshot_timeout_seconds(source_path: Path) -> float:
|
||||
"""Bound one SQLite snapshot by source size incl. sidecars (a WAL can hold committed rows not yet in the
|
||||
main file), so a healthy large-database copy is not cut off by the repair-lock timeout."""
|
||||
from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS, _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND
|
||||
from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS
|
||||
source_bytes = 0
|
||||
for candidate in (source_path, *_sidecars(source_path)):
|
||||
with contextlib.suppress(FileNotFoundError): # a sidecar may vanish mid-walk
|
||||
@@ -584,15 +583,14 @@ def _connect_repair_durable(db_path: Path, *, timeout: float = 5.0) -> sqlite3.C
|
||||
|
||||
def _repair_conn(db_path: Path, *, timeout: float = 5.0):
|
||||
"""A :func:`_connect_repair_durable` connection as a context manager, closed on exit."""
|
||||
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name>
|
||||
return contextlib.closing(_connect(db_path, timeout=timeout))
|
||||
return contextlib.closing(_connect_repair_durable(db_path, timeout=timeout))
|
||||
|
||||
|
||||
def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool:
|
||||
"""Best-effort (re)application of the macOS write barriers; True if accepted.
|
||||
Call before ``VACUUM``/``REINDEX`` once the schema parses: a connection opened
|
||||
on a malformed schema could not take them at open time. Never raises."""
|
||||
from hermes_state import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full
|
||||
from hermes_state_wal import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full
|
||||
try:
|
||||
_apply_macos_checkpoint_barrier(conn)
|
||||
_enforce_macos_synchronous_full(conn)
|
||||
@@ -605,7 +603,7 @@ def apply_durability_barriers(conn: sqlite3.Connection) -> bool:
|
||||
"""Durability barriers for guest users of ``state.db`` that must inherit its owner's journal mode. Also
|
||||
applies the configured ``database.synchronous`` level, a per-connection pragma that otherwise only
|
||||
rides on the journal-mode setup path guests must not run."""
|
||||
from hermes_state import _apply_synchronous_pragma
|
||||
from hermes_state_wal import _apply_synchronous_pragma
|
||||
ok = _reapply_durability_barriers(conn)
|
||||
with contextlib.suppress(Exception):
|
||||
from hermes_cli.config import cfg_get, load_config_readonly # local: avoids an import cycle
|
||||
@@ -624,8 +622,7 @@ def _close_unpinned(conn: sqlite3.Connection) -> None:
|
||||
def _open_exclusive(db_path: Path, begin: str) -> sqlite3.Connection:
|
||||
"""Zero-timeout connection holding ``locking_mode=EXCLUSIVE`` after a rolled-back
|
||||
*begin*; closed (unpinned) and re-raised when exclusion cannot be taken."""
|
||||
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name>
|
||||
conn = _connect(db_path, timeout=0.0)
|
||||
conn = _connect_repair_durable(db_path, timeout=0.0)
|
||||
try:
|
||||
for statement in ("PRAGMA locking_mode=EXCLUSIVE", begin, "ROLLBACK"):
|
||||
conn.execute(statement)
|
||||
@@ -702,8 +699,7 @@ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
|
||||
# of entries in index" when a B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its
|
||||
# base table. REINDEX rewrites the index b-tree from the canonical table rows using the existing index
|
||||
# definition, fixing the mismatch without touching data or FTS schema.
|
||||
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name>
|
||||
conn = _connect(db_path)
|
||||
conn = _connect_repair_durable(db_path)
|
||||
try:
|
||||
with contextlib.closing(conn):
|
||||
# Best-effort tokenizer load: messages_fts_cjk needs cjk_unicode61 before any statement can touch it;
|
||||
@@ -766,8 +762,7 @@ def _live_writer_holds_db(db_path: Path) -> bool:
|
||||
repair is then serialised only by the cross-process repairer lock. Before probing, the foreign-holder scan
|
||||
(``hermes_state_holders``) fails closed on deleted-WAL-generation, uninspectable, or unknown holders."""
|
||||
import hermes_state_holders as _state_holders
|
||||
from hermes_state import _connect_repair_durable as _connect # call-time lookup: tests patch hermes_state.<name>
|
||||
return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect)
|
||||
return _state_holders.live_writer_holds_db(db_path, connect_repair_durable=_connect_repair_durable)
|
||||
|
||||
|
||||
def _repair_skip(report: Dict[str, Any], verb: str, error: str, exc: Optional[BaseException] = None) -> Dict[str, Any]:
|
||||
@@ -794,9 +789,6 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A
|
||||
|
||||
See #50502.
|
||||
"""
|
||||
from hermes_state import (_cross_process_repair_lock, _db_opens_cleanly, _live_writer_holds_db,
|
||||
_persistent_repair_attempts_exhausted, _probe_journal_mode_for_repair,
|
||||
_record_repair_outcome, _repair_state_db_schema_locked)
|
||||
report: Dict[str, Any] = {"repaired": False, "strategy": None, "backup_path": None, "error": None}
|
||||
# Startup-watchdog lease: repair is I/O-bound (near-zero CPU), which the watchdog's CPU fallback would
|
||||
# misread as a parked deadlock. One lease (clamped to _MAX_LEASE_S=900) beats per-chunk renewal complexity.
|
||||
@@ -858,7 +850,7 @@ def _probe_journal_mode_for_repair(db_path: Path) -> Optional[str]:
|
||||
"""Best-effort journal-mode probe: ``wal``/``delete``, or ``None`` when the file cannot be opened or
|
||||
probed (malformed header, concurrent opener's locks — both expected on the repair path); callers then
|
||||
fall back to ``database.journal_mode``."""
|
||||
from hermes_state import _on_disk_journal_mode
|
||||
from hermes_state_wal import _on_disk_journal_mode
|
||||
try:
|
||||
with _repair_conn(db_path) as conn:
|
||||
return _on_disk_journal_mode(conn)
|
||||
@@ -885,7 +877,7 @@ def _restore_journal_mode_after_repair(db_path: Path, before_mode: Optional[str]
|
||||
The transactional promotion already leaves the destination in its pre-repair mode, so on that path this
|
||||
is mostly the WAL-companion re-assertion; the reopen is the hazard, not the mode. See #101064.
|
||||
"""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
try:
|
||||
if conn is None:
|
||||
with _repair_conn(db_path) as owned:
|
||||
@@ -917,9 +909,6 @@ def _repair_state_db_schema_locked(
|
||||
so recovery still depends on a human noticing a ``.malformed-backup-*`` file and knowing what to do with
|
||||
it. Not mutating the original in the first place is the property that holds without a human in the loop.
|
||||
"""
|
||||
from hermes_state import (_backup_db_file, _copy_database_snapshot, _db_opens_cleanly, _exclusive_repair_db_guard,
|
||||
_repair_scratch_space_error, _restore_journal_mode_after_repair, _run_repair_strategies,
|
||||
_unlink_db_triple)
|
||||
scratch = db_path.with_name(f"{db_path.name}.repair-scratch")
|
||||
if (cleanup_error := _unlink_db_triple(scratch)) is not None:
|
||||
return _repair_skip(report, "aborted", f"could not remove a stale repair snapshot before probing state.db: {cleanup_error}")
|
||||
@@ -1074,7 +1063,7 @@ _REPAIR_STRATEGIES = (
|
||||
def _run_repair_strategies(db_path: Path, report: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Escalating repair attempts, applied to *db_path* IN PLACE — only ever a scratch copy nothing else holds open,
|
||||
never the user's database. The "could not recover" log lives in the caller so it names the user's database."""
|
||||
from hermes_state import _db_opens_cleanly
|
||||
|
||||
for name, body, success_msg, failure_msg in _REPAIR_STRATEGIES:
|
||||
try:
|
||||
with _repair_conn(db_path) as conn:
|
||||
|
||||
@@ -601,21 +601,6 @@ class SessionSessionsMixin:
|
||||
("", ActivityProvenance.UNKNOWN.value, session_id), patience_s=self._ACTIVITY_WRITE_PATIENCE_S,
|
||||
)
|
||||
|
||||
def get_session_activity(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the durable activity snapshot for *session_id*, or None."""
|
||||
if not session_id:
|
||||
return None
|
||||
row = self.get_session(session_id)
|
||||
if not row:
|
||||
return None
|
||||
from agent.session_activity import build_activity_snapshot
|
||||
|
||||
return build_activity_snapshot(
|
||||
last_activity_at=row.get("last_activity_at"),
|
||||
last_activity_description=row.get("last_activity_description"),
|
||||
last_activity_provenance=row.get("last_activity_provenance"),
|
||||
)
|
||||
|
||||
def update_session_meta(
|
||||
self, session_id: str, model_config_json: str, model: Optional[str] = None,
|
||||
) -> None:
|
||||
|
||||
@@ -127,10 +127,6 @@ class SessionTitlesMixin:
|
||||
raise ValueError(f"invalid automatic title source: {source!r}")
|
||||
return self._set_session_title(session_id, title, source=source)
|
||||
|
||||
def set_auto_title_if_empty(self, session_id: str, title: str) -> bool:
|
||||
"""Back-compat shim (third-party plugins reference it by name)."""
|
||||
return self.set_auto_title(session_id, title, source=self.TITLE_SOURCE_LLM)
|
||||
|
||||
def get_session_title(self, session_id: str) -> Optional[str]:
|
||||
"""Get the title for a session, or None."""
|
||||
row = self._read_one("SELECT title FROM sessions WHERE id = ?", (session_id,))
|
||||
|
||||
+4
-7
@@ -1,7 +1,6 @@
|
||||
"""SQLite journal-mode and PRAGMA policy for state.db (split from hermes_state).
|
||||
|
||||
Every name is re-imported into ``hermes_state``; intra-module calls to patchable helpers go through a lazy
|
||||
``from hermes_state import ...`` at call time so monkeypatches there still intercept.
|
||||
Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_wal.<name>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,7 +29,7 @@ _WAL_INCOMPAT_MARKERS = ("locking protocol", "not authorized", "disk i/o error")
|
||||
_WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
|
||||
|
||||
# Once-per-process-per-db_label dedup sets (kanban_db.connect() runs on every kanban operation, so an undeduped
|
||||
# line would repeat per connection). Tests clear these via ``hermes_state.<name>``; ``_log_once`` resolves them there.
|
||||
# line would repeat per connection). Tests clear these via ``hermes_state_wal.<name>``.
|
||||
_wal_fallback_warned_paths: set[str] = set()
|
||||
_wal_fallback_warned_lock = threading.Lock()
|
||||
_wal_reset_bug_warned_paths: set[str] = set()
|
||||
@@ -174,7 +173,7 @@ def _verify_configured_delete(actual: str) -> str:
|
||||
def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str:
|
||||
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
|
||||
|
||||
Returns the mode actually set. Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect``.
|
||||
Returns the mode actually set. Shared by :class:`SessionDB` and ``hermes_cli.kanban_db_connect.connect``.
|
||||
WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
|
||||
macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per
|
||||
``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug
|
||||
@@ -193,7 +192,6 @@ def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.
|
||||
still documents the WAL-reset bug as real through 3.51.2 with serious consequences. Until a fixed
|
||||
runtime is delivered, keep new databases out of WAL.
|
||||
"""
|
||||
from hermes_state import is_sqlite_wal_reset_vulnerable, resolve_journal_mode
|
||||
configured = resolve_journal_mode()
|
||||
|
||||
# Vulnerable SQLite: never enable WAL on non-WAL files (configured mode resolved first so an explicit DELETE
|
||||
@@ -397,9 +395,8 @@ _ONCE_LOGS = {
|
||||
def _log_once(kind: str, db_label: str, *args: Any) -> None:
|
||||
"""Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are
|
||||
resolved only after the dedupe check, so install-method probes run once."""
|
||||
import hermes_state
|
||||
lock, set_name, level, message = _ONCE_LOGS[kind]
|
||||
seen = getattr(hermes_state, set_name)
|
||||
seen = globals()[set_name]
|
||||
with lock:
|
||||
if db_label in seen:
|
||||
return
|
||||
|
||||
+3
-3
@@ -78,8 +78,8 @@ def _close_quietly(db, what: str) -> None:
|
||||
def _get_session_db():
|
||||
"""SessionDB instance for reading message transcripts, or None."""
|
||||
try:
|
||||
from hermes_state import get_shared_session_db
|
||||
return get_shared_session_db()
|
||||
from hermes_state_registry import acquire
|
||||
return acquire()
|
||||
except Exception as e:
|
||||
logger.debug("SessionDB unavailable: %s", e)
|
||||
return None
|
||||
@@ -96,7 +96,7 @@ def _load_session_messages(session_id: str):
|
||||
return None, f"Failed to read messages: {e}"
|
||||
finally:
|
||||
try:
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
release_or_close(db)
|
||||
except Exception:
|
||||
logger.debug("Failed to close MCP SessionDB", exc_info=True)
|
||||
|
||||
@@ -125,7 +125,7 @@ class MemoryStore:
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create schema, enable WAL via the shared fallback helper (NFS/SMB/FUSE degrade gracefully), add hrr_vector to pre-HRR DBs."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
if "hrr_vector" not in {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}:
|
||||
|
||||
@@ -53,7 +53,7 @@ class DiscordRecoveryStore:
|
||||
return default
|
||||
|
||||
def _initialize(self, conn: sqlite3.Connection) -> None:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
apply_wal_with_fallback(conn, db_label="discord_recovery.db")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS discord_messages (
|
||||
|
||||
+3
-3
@@ -289,9 +289,9 @@ class AIAgent(
|
||||
if self._session_db is not None:
|
||||
return self._session_db
|
||||
try:
|
||||
from hermes_state import get_shared_session_db
|
||||
from hermes_state_registry import acquire
|
||||
|
||||
self._session_db = get_shared_session_db()
|
||||
self._session_db = acquire()
|
||||
self._owns_session_db = True # we opened it, so close() must release it
|
||||
return self._session_db
|
||||
except Exception:
|
||||
@@ -998,7 +998,7 @@ class AIAgent(
|
||||
self._owns_session_db = False
|
||||
# Shared instances no-op on close(); release the refcount so the registry closes on the last caller.
|
||||
# See #90837.
|
||||
from hermes_state import release_or_close
|
||||
from hermes_state_registry import release_or_close
|
||||
release_or_close(session_db)
|
||||
|
||||
def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent.conversation_compression import recover_rotated_compression_session
|
||||
from hermes_state import CompressionSessionClosedError, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
|
||||
|
||||
def test_recover_rotated_compression_session_reopens_legacy_orphan(tmp_path):
|
||||
|
||||
+5
-5
@@ -99,7 +99,7 @@ if _hermes_home_points_at_production(os.environ.get("HERMES_HOME", "")):
|
||||
# the child at the same moment the child lost the HERMES_HOME redirect.
|
||||
# HERMES_TEST_ISOLATION is OUR marker: exported here (before any test module
|
||||
# imports), inherited by every child by default, and honored by
|
||||
# hermes_state._running_under_pytest() as a test-context signal. A child
|
||||
# hermes_state_guard._running_under_pytest() as a test-context signal. A child
|
||||
# that carries it and still resolves the production state.db fails hard.
|
||||
# Tests that legitimately need a child to look like a non-test process AND
|
||||
# open a real DB must export HERMES_STATE_DB_GUARD_BYPASS=1 in that child's
|
||||
@@ -647,7 +647,7 @@ def _neutralize_macos_keychain_creds(request, monkeypatch):
|
||||
# ── Kanban write guard (#69283) ─────────────────────────────────────────────
|
||||
# When hermetic isolation is bypassed (stale checkout, wrong rootdir, direct
|
||||
# invocation), kanban writes silently pollute the real ~/.hermes. This autouse
|
||||
# fixture patches ``kanban_db.connect`` to refuse writes whose resolved DB
|
||||
# fixture patches ``kanban_db_connect.connect`` to refuse writes whose resolved DB
|
||||
# path lands under the REAL kanban root (captured at import time, before any
|
||||
# fixture rewires the environment). A deny-list is used instead of an
|
||||
# allow-list because test-level fixtures legitimately move HERMES_HOME to
|
||||
@@ -711,8 +711,8 @@ def _kanban_write_guard(_hermetic_environment, monkeypatch):
|
||||
# doesn't exist yet (AttributeError flake, caught in a full-suite run).
|
||||
# A half-imported module has no callers yet either — nothing to guard
|
||||
# this round; the next test's fixture will patch the completed module.
|
||||
_orig_connect = getattr(_kdb, "connect", None)
|
||||
if _orig_connect is None:
|
||||
_orig_connect = getattr(_kdbc, "connect", None)
|
||||
if _orig_connect is None or getattr(_kdb, "kanban_db_path", None) is None:
|
||||
return
|
||||
|
||||
def _guarded_connect(db_path=None, *args, **kwargs):
|
||||
@@ -736,7 +736,7 @@ def _kanban_write_guard(_hermetic_environment, monkeypatch):
|
||||
f"to the real ~/.hermes. See #69283."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kdb, "connect", _guarded_connect)
|
||||
monkeypatch.setattr(_kdbc, "connect", _guarded_connect)
|
||||
|
||||
|
||||
# ── Live state.db write guard ───────────────────────────────────────────────
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_run_job_bounds_sessiondb_finalization(tmp_path):
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls, \
|
||||
patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02):
|
||||
@@ -118,7 +118,7 @@ def test_dispatch_guard_releases_after_sessiondb_finalization_hang(tmp_path):
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=_RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls, \
|
||||
patch("cron.scheduler._cron_cleanup_timeout_seconds", return_value=0.02), \
|
||||
|
||||
@@ -54,7 +54,7 @@ def _run_with_current_provider(job, current_provider, tmp_path):
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -259,7 +259,7 @@ def _run_with_current_provider_and_model(
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -423,7 +423,7 @@ class TestRuntimeResolutionTargetModel:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
side_effect=_capture,
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestRunJobRequestOverrides:
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
|
||||
@@ -553,7 +553,7 @@ class TestRunJobSessionPersistence:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -611,7 +611,7 @@ class TestRunJobSessionPersistence:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None),
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"),
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"),
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db),
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -753,7 +753,7 @@ class TestRunJobSessionPersistence:
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._preflight_job_config", return_value=None), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -813,7 +813,7 @@ class TestRunJobSessionPersistence:
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._preflight_job_config", return_value=None), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -874,7 +874,7 @@ class TestRunJobSessionPersistence:
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._preflight_job_config", return_value=None), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -924,7 +924,7 @@ class TestRunJobSessionPersistence:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache", _record_reset), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv", _record_load), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -985,7 +985,7 @@ class TestRunJobSessionPersistence:
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._preflight_job_config", return_value=None), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -1083,7 +1083,7 @@ class TestRunJobConfigEnvVarExpansion:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
@@ -1233,7 +1233,7 @@ class TestRunJobConfigEnvVarExpansion:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
@@ -1278,7 +1278,7 @@ class TestRunJobModelResolution:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
@@ -1304,7 +1304,7 @@ class TestRunJobModelResolution:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
@@ -1336,7 +1336,7 @@ class TestRunJobModelResolution:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
@@ -1361,7 +1361,7 @@ class TestRunJobModelResolution:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
@@ -1406,7 +1406,7 @@ class TestRunJobSkillBacked:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_run_job_cron_execute_code_deny_does_not_pollute_later_gateway_execute_c
|
||||
monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False)
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny")
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _DummySessionDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", _DummySessionDB)
|
||||
monkeypatch.setattr("run_agent.AIAgent", _FakeCronAgent)
|
||||
monkeypatch.setattr(
|
||||
"hermes_constants.resolve_reasoning_config", lambda *_args, **_kwargs: None
|
||||
|
||||
@@ -250,7 +250,7 @@ def test_long_running_script_refreshes_owned_claim_in_profile_store(
|
||||
|
||||
with (
|
||||
jobs.use_cron_store(profile_home),
|
||||
patch("hermes_state.get_shared_session_db", return_value=MagicMock()),
|
||||
patch("hermes_state_registry.acquire", return_value=MagicMock()),
|
||||
):
|
||||
success, _doc, _response, error = scheduler.run_job(claimed_job)
|
||||
profile_claim = jobs.get_job("long-script")["run_claim"]
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestSessionDbInitTimeout:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", side_effect=make_session_db), \
|
||||
patch("hermes_state_registry.acquire", side_effect=make_session_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=_RUNTIME,
|
||||
@@ -131,7 +131,7 @@ class TestSessionDbInitTimeout:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db"), \
|
||||
patch("hermes_state_registry.acquire"), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=_RUNTIME,
|
||||
@@ -166,7 +166,7 @@ class TestSessionDbInitTimeout:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
|
||||
patch("hermes_state_registry.acquire", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=_RUNTIME,
|
||||
@@ -209,7 +209,7 @@ class TestSessionDbInitTimeout:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db"), \
|
||||
patch("hermes_state_registry.acquire"), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=_RUNTIME,
|
||||
@@ -259,7 +259,7 @@ class TestDispatchGuardReleasedAfterHang:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db"), \
|
||||
patch("hermes_state_registry.acquire"), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=_RUNTIME,
|
||||
@@ -357,7 +357,7 @@ class TestLateSessionDbClosedAfterTimeout:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db", side_effect=_hanging_then_capture), \
|
||||
patch("hermes_state_registry.acquire", side_effect=_hanging_then_capture), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
@@ -415,7 +415,7 @@ class TestSessionDbInitAfterEarlyReturns:
|
||||
patch("cron.scheduler_delivery._resolve_origin", return_value=None), \
|
||||
patch("hermes_cli.env_loader.load_hermes_dotenv"), \
|
||||
patch("hermes_cli.env_loader.reset_secret_source_cache"), \
|
||||
patch("hermes_state.get_shared_session_db") as mock_db_cls, \
|
||||
patch("hermes_state_registry.acquire") as mock_db_cls, \
|
||||
patch(
|
||||
"cron.scheduler._run_job_script_with_claim_heartbeat",
|
||||
return_value=(True, '{"wakeAgent": false}'),
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from gateway import hosted_room_driver as driver
|
||||
from gateway import hosted_rooms as rooms
|
||||
import hermes_state
|
||||
import hermes_state_wal
|
||||
from gateway.hosted_room_policy_checkpoint import HostedRoomPolicyCheckpoint
|
||||
from hermes_state import SessionDB
|
||||
|
||||
@@ -167,7 +168,7 @@ def test_first_database_open_retries_only_transient_journal_lock(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
original = hermes_state.apply_wal_with_fallback
|
||||
original = hermes_state_wal.apply_wal_with_fallback
|
||||
attempts = 0
|
||||
|
||||
def transient_lock(conn, **kwargs):
|
||||
@@ -177,7 +178,7 @@ def test_first_database_open_retries_only_transient_journal_lock(
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
return original(conn, **kwargs)
|
||||
|
||||
monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", transient_lock)
|
||||
monkeypatch.setattr(hermes_state_wal, "apply_wal_with_fallback", transient_lock)
|
||||
|
||||
assert _create(tmp_path / "state.db")["room_id"] == "room-1"
|
||||
assert attempts == 3
|
||||
@@ -197,8 +198,7 @@ def test_first_database_open_does_not_retry_other_journal_errors(
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"apply_wal_with_fallback",
|
||||
hermes_state_wal, "apply_wal_with_fallback",
|
||||
configured_delete_refusal,
|
||||
)
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ class TestAppendToSqlite:
|
||||
mock_db = MagicMock()
|
||||
released = []
|
||||
|
||||
with patch("hermes_state.get_shared_session_db", return_value=mock_db), \
|
||||
with patch("hermes_state_registry.acquire", return_value=mock_db), \
|
||||
patch(
|
||||
"hermes_state.release_or_close",
|
||||
"hermes_state_registry.release_or_close",
|
||||
side_effect=lambda db: released.append(db),
|
||||
):
|
||||
_append_to_sqlite("sess_1", {"role": "assistant", "content": "hello"})
|
||||
|
||||
@@ -7,7 +7,7 @@ survivor. A gateway closing a 500MB WAL store runs a PASSIVE checkpoint in
|
||||
autocheckpoint threshold) that does not reliably finish in 5s. A SIGKILL
|
||||
landing mid-checkpoint leaves half-written b-tree pages — macOS ``fsync``
|
||||
guarantees neither data-on-platter nor write ordering, which is exactly why
|
||||
``hermes_state._enforce_macos_synchronous_full`` exists.
|
||||
``hermes_state_wal._enforce_macos_synchronous_full`` exists.
|
||||
|
||||
The port-rebinding reason the 5s deadline was introduced still holds, so the
|
||||
force-kill stays — it just must not fire on a process that is still shutting
|
||||
@@ -17,6 +17,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state_wal
|
||||
|
||||
from hermes_cli.gateway import (
|
||||
_ORPHAN_EXIT_GRACE_SECONDS,
|
||||
_await_gateway_exit,
|
||||
|
||||
@@ -1475,7 +1475,7 @@ class TestGatewaySessionDbRecovery:
|
||||
def test_transcript_reroute_migrates_remaining_backlog_to_child(self):
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from hermes_state import CompressionSessionClosedError
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
|
||||
class FakeDb:
|
||||
def get_compression_tip(self, session_id):
|
||||
|
||||
@@ -108,6 +108,7 @@ def test_runtime_health_is_sanitized_and_recovers() -> None:
|
||||
|
||||
def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch, tmp_path) -> None:
|
||||
import hermes_state
|
||||
import hermes_state_registry
|
||||
from gateway.run import GatewayRunner, _SESSION_DB_UNPINNED
|
||||
from gateway.session import SessionStore
|
||||
from gateway.session_persistence import _DB_UNPINNED
|
||||
@@ -124,7 +125,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
|
||||
opened.append(handle)
|
||||
return handle
|
||||
|
||||
monkeypatch.setattr(hermes_state, "get_shared_session_db", fail_once_session_db)
|
||||
monkeypatch.setattr(hermes_state_registry, "acquire", fail_once_session_db)
|
||||
monkeypatch.setattr(hermes_state, "_default_db_path", lambda: db_path)
|
||||
|
||||
store = object.__new__(SessionStore)
|
||||
@@ -153,7 +154,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
|
||||
runner_opened.append(handle)
|
||||
return handle
|
||||
|
||||
monkeypatch.setattr(hermes_state, "get_shared_session_db", runner_fail_once)
|
||||
monkeypatch.setattr(hermes_state_registry, "acquire", runner_fail_once)
|
||||
monkeypatch.setattr(hermes_state, "AsyncSessionDB", lambda db: ("async", db))
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._session_db_pinned = _SESSION_DB_UNPINNED
|
||||
|
||||
@@ -123,9 +123,9 @@ def test_recover_closes_owned_db_when_unexpected_exception_escapes(
|
||||
raise KeyboardInterrupt
|
||||
|
||||
db = InterruptingDB()
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", lambda: db)
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.release_or_close", lambda _: setattr(db, "released", True)
|
||||
"hermes_state_registry.release_or_close", lambda _: setattr(db, "released", True)
|
||||
)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
|
||||
@@ -19,6 +19,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state_repair
|
||||
|
||||
import hermes_startup_watchdog as sw
|
||||
from hermes_startup_watchdog import (
|
||||
SERVICE_RESTART_EXIT_CODE,
|
||||
@@ -453,7 +455,7 @@ class TestProgressLease:
|
||||
|
||||
import hermes_state
|
||||
|
||||
src = inspect.getsource(hermes_state.repair_state_db_schema)
|
||||
src = inspect.getsource(hermes_state_repair.repair_state_db_schema)
|
||||
assert "report_startup_progress" in src
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,16 @@ from agent.session_activity import ActivityProvenance, build_activity_snapshot
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
def _activity_snapshot(db, session_id):
|
||||
"""Durable activity snapshot for *session_id* (what gateway/delegate readers build from the row)."""
|
||||
row = db.get_session(session_id)
|
||||
return build_activity_snapshot(
|
||||
last_activity_at=row.get("last_activity_at"),
|
||||
last_activity_description=row.get("last_activity_description"),
|
||||
last_activity_provenance=row.get("last_activity_provenance"),
|
||||
)
|
||||
|
||||
|
||||
# ── S1: observational activity writes must not ride the 20s patience ────────
|
||||
|
||||
|
||||
@@ -77,7 +87,7 @@ def test_s1_clear_labels_noop_skips_transaction(tmp_path, monkeypatch):
|
||||
calls.clear()
|
||||
db.clear_session_activity_labels(sid)
|
||||
assert len(calls) == 1
|
||||
activity = db.get_session_activity(sid)
|
||||
activity = _activity_snapshot(db, sid)
|
||||
assert activity["last_activity_description"] == ""
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
import hermes_state_wal
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
|
||||
@@ -74,7 +75,7 @@ def test_cross_process_init_lock_uses_windows_byte_range_lock(tmp_path, monkeypa
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt)
|
||||
|
||||
db_path = tmp_path / "kanban.db"
|
||||
with kb._cross_process_init_lock(db_path):
|
||||
with kbc._cross_process_init_lock(db_path):
|
||||
# Acquired exactly once via the non-blocking byte-range lock.
|
||||
assert [call[1:] for call in calls] == [(fake_msvcrt.LK_NBLCK, 1)]
|
||||
|
||||
@@ -873,7 +874,7 @@ class TestSharedBoardPaths:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NFS / network-filesystem fallback (see hermes_state.apply_wal_with_fallback)
|
||||
# NFS / network-filesystem fallback (see hermes_state_wal.apply_wal_with_fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch, caplog):
|
||||
@@ -903,16 +904,16 @@ def test_connect_falls_back_to_delete_on_locking_protocol(tmp_path, monkeypatch,
|
||||
|
||||
# These tests exercise the WAL-attempt path; assume a fixed SQLite so the
|
||||
# WAL-reset vulnerability gate doesn't short-circuit before the pragma.
|
||||
import hermes_state as _hermes_state
|
||||
import hermes_state_wal as _hermes_state_wal
|
||||
monkeypatch.setattr(
|
||||
_hermes_state, "is_sqlite_wal_reset_vulnerable",
|
||||
_hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
|
||||
lambda version_info=None: False,
|
||||
)
|
||||
_hermes_state._wal_fallback_warned_paths.clear()
|
||||
_hermes_state_wal._wal_fallback_warned_paths.clear()
|
||||
|
||||
# Clear module cache so a fresh connect() is attempted
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
hermes_state._wal_fallback_warned_paths.clear()
|
||||
hermes_state_wal._wal_fallback_warned_paths.clear()
|
||||
|
||||
real_connect = _sqlite3.connect
|
||||
|
||||
@@ -963,10 +964,10 @@ def test_connect_works_when_wal_is_silently_refused(tmp_path, monkeypatch, caplo
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
hermes_state._wal_fallback_warned_paths.clear()
|
||||
hermes_state_wal._wal_fallback_warned_paths.clear()
|
||||
# Assume a fixed SQLite so the WAL-reset gate doesn't short-circuit.
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable",
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
|
||||
lambda version_info=None: False,
|
||||
)
|
||||
|
||||
@@ -1033,7 +1034,7 @@ def test_sqlite_connect_closes_tracked_conn_on_setup_failure(tmp_path, monkeypat
|
||||
monkeypatch.setattr(kb.sqlite3, "connect", failing_connect)
|
||||
|
||||
with pytest.raises(sqlite3.OperationalError, match="simulated setup failure"):
|
||||
kb._sqlite_connect(db_path)
|
||||
kbc._sqlite_connect(db_path)
|
||||
|
||||
with sqlite_safe_read._live_lock:
|
||||
after = sqlite_safe_read._live_connections.get(key, 0)
|
||||
@@ -1627,7 +1628,7 @@ def test_write_txn_check_reads_correct_header_fields(tmp_path):
|
||||
# connect_closing(): context manager that actually closes the FD
|
||||
# Regression coverage for #33159 (kanban.db FD leak — gateway crashes after
|
||||
# ~4 days). sqlite3.Connection's built-in __exit__ commits/rollbacks but
|
||||
# does NOT close, so `with kb.connect() as conn:` leaks the FD in
|
||||
# does NOT close, so `with kbc.connect() as conn:` leaks the FD in
|
||||
# long-lived processes (gateway run_slash, dashboard decompose handler).
|
||||
# `connect_closing()` is the leak-safe replacement.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,8 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import FTS_STORAGE_VERSION, SCHEMA_VERSION, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_STORAGE_VERSION, SCHEMA_VERSION
|
||||
from hermes_cli import session_recovery
|
||||
from hermes_cli.session_recovery import (
|
||||
SessionRecoverySafetyError,
|
||||
|
||||
@@ -26,6 +26,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state_repair
|
||||
|
||||
from hermes_cli.session_lost_and_found import (
|
||||
_parse_sqlite3_cli_version,
|
||||
_wal_reset_vulnerable,
|
||||
@@ -245,7 +247,7 @@ class TestGuidanceNeverNamesLiveDb:
|
||||
must not embed a raw sqlite3 command against the live path."""
|
||||
import hermes_state
|
||||
|
||||
body = inspect.getsource(hermes_state._backup_db_file)
|
||||
body = inspect.getsource(hermes_state_repair._backup_db_file)
|
||||
assert ".recover\"`" not in body
|
||||
assert "sessions recover --source" in body
|
||||
assert "--inspect-only" in body
|
||||
|
||||
@@ -15,22 +15,18 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
DeletedWalGenerationError,
|
||||
SessionDB,
|
||||
classify_persistence_error,
|
||||
iter_deleted_sqlite_sidecar_holders,
|
||||
refuse_deleted_wal_generation,
|
||||
)
|
||||
import hermes_state_wal
|
||||
from hermes_state import DeletedWalGenerationError, SessionDB, classify_persistence_error, refuse_deleted_wal_generation
|
||||
from hermes_state_dbfile import iter_deleted_sqlite_sidecar_holders
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def force_wal(monkeypatch):
|
||||
"""Pin WAL so this host's vulnerable SQLite still matches production topology."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
)
|
||||
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "wal")
|
||||
monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "wal")
|
||||
|
||||
|
||||
def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
|
||||
@@ -87,9 +83,9 @@ def test_clean_open_and_second_open_still_work(tmp_path, force_wal):
|
||||
|
||||
|
||||
def test_delete_journal_two_writers_still_work(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete")
|
||||
monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "delete")
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
)
|
||||
path = tmp_path / "state.db"
|
||||
a = _make_db(path, "s", "from-a")
|
||||
|
||||
@@ -20,6 +20,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_state
|
||||
import hermes_state_guard
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -27,13 +28,14 @@ _CHILD_PROBE = r"""
|
||||
import json, os, sys
|
||||
sys.path.insert(0, {repo!r})
|
||||
import hermes_state as hs
|
||||
import hermes_state_guard
|
||||
fired = False
|
||||
try:
|
||||
hs._ensure_test_isolation(hs._real_platform_state_root() / "state.db")
|
||||
except RuntimeError:
|
||||
fired = True
|
||||
print(json.dumps({{
|
||||
"armed": hs._running_under_pytest(),
|
||||
"armed": hermes_state_guard._running_under_pytest(),
|
||||
"fired": fired,
|
||||
}}))
|
||||
"""
|
||||
@@ -78,7 +80,7 @@ def test_marker_alone_reports_test_context(monkeypatch):
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.delenv("PYTEST_VERSION", raising=False)
|
||||
monkeypatch.setenv("HERMES_TEST_ISOLATION", "/tmp/some-isolation-root")
|
||||
assert hermes_state._running_under_pytest() is True
|
||||
assert hermes_state_guard._running_under_pytest() is True
|
||||
|
||||
|
||||
def test_no_signals_reports_production(monkeypatch):
|
||||
@@ -87,7 +89,7 @@ def test_no_signals_reports_production(monkeypatch):
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.delenv("PYTEST_VERSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_TEST_ISOLATION", raising=False)
|
||||
assert hermes_state._running_under_pytest() is False
|
||||
assert hermes_state_guard._running_under_pytest() is False
|
||||
|
||||
|
||||
def test_child_with_rebuilt_env_keeping_marker_refuses_production_db():
|
||||
|
||||
@@ -29,6 +29,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
import hermes_state_guard
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -137,7 +138,7 @@ class TestPytestProcessRecognition:
|
||||
],
|
||||
)
|
||||
def test_recognises_pytest_invocations(self, cmdline):
|
||||
assert hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
|
||||
assert hermes_state_guard._process_looks_like_pytest(self._FakeProc(cmdline))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmdline",
|
||||
@@ -150,11 +151,11 @@ class TestPytestProcessRecognition:
|
||||
],
|
||||
)
|
||||
def test_ignores_non_pytest_invocations(self, cmdline):
|
||||
assert not hermes_state._process_looks_like_pytest(self._FakeProc(cmdline))
|
||||
assert not hermes_state_guard._process_looks_like_pytest(self._FakeProc(cmdline))
|
||||
|
||||
def test_unreadable_process_is_not_pytest(self):
|
||||
class _Denied:
|
||||
def cmdline(self):
|
||||
raise PermissionError("access denied")
|
||||
|
||||
assert not hermes_state._process_looks_like_pytest(_Denied())
|
||||
assert not hermes_state_guard._process_looks_like_pytest(_Denied())
|
||||
|
||||
@@ -165,7 +165,7 @@ def test_flush_adopts_exactly_once_no_retry_loop(tmp_path: Path, monkeypatch) ->
|
||||
"""Adoption budget: the tip lookup runs at most once per flush, and a
|
||||
second closed-parent write after adoption fails closed instead of looping.
|
||||
"""
|
||||
from hermes_state import CompressionSessionClosedError
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
try:
|
||||
@@ -205,11 +205,8 @@ def test_flush_adopts_exactly_once_no_retry_loop(tmp_path: Path, monkeypatch) ->
|
||||
|
||||
|
||||
def test_compression_closed_error_classifies_as_compression_closed() -> None:
|
||||
from hermes_state import (
|
||||
PERSISTENCE_ERROR_CAUSES,
|
||||
CompressionSessionClosedError,
|
||||
classify_persistence_error,
|
||||
)
|
||||
from hermes_state import classify_persistence_error
|
||||
from hermes_state_errors import CompressionSessionClosedError, PERSISTENCE_ERROR_CAUSES
|
||||
|
||||
cause = classify_persistence_error(CompressionSessionClosedError("session-abc"))
|
||||
assert cause == "compression_closed"
|
||||
@@ -222,7 +219,8 @@ def test_compression_closed_error_classifies_as_compression_closed() -> None:
|
||||
|
||||
|
||||
def test_compression_closed_wording_never_mentions_disk() -> None:
|
||||
from hermes_state import CompressionSessionClosedError, classify_persistence_error
|
||||
from hermes_state import classify_persistence_error
|
||||
from hermes_state_errors import CompressionSessionClosedError
|
||||
|
||||
text = AIAgent._format_turn_completion_explanation(
|
||||
"session_persistence_failed",
|
||||
|
||||
@@ -48,7 +48,7 @@ class TestCronJobCleanup:
|
||||
"model": "test/model",
|
||||
}
|
||||
|
||||
with patch("hermes_state.get_shared_session_db", return_value=mock_db), \
|
||||
with patch("hermes_state_registry.acquire", return_value=mock_db), \
|
||||
patch.object(scheduler, "_build_job_prompt", return_value="hello"), \
|
||||
patch.object(sched_delivery, "_resolve_origin", return_value=None), \
|
||||
patch.object(scheduler, "_resolve_delivery_target", return_value=None), \
|
||||
|
||||
@@ -68,8 +68,10 @@ def test_session_search_lazily_opens_db_when_entrypoint_did_not_pass_one(monkeyp
|
||||
|
||||
hermes_state = ModuleType("hermes_state")
|
||||
hermes_state.SessionDB = FakeSessionDB
|
||||
hermes_state.get_shared_session_db = lambda db_path=None: sentinel_db
|
||||
monkeypatch.setitem(sys.modules, "hermes_state", hermes_state)
|
||||
hermes_state_registry = ModuleType("hermes_state_registry")
|
||||
hermes_state_registry.acquire = lambda db_path=None: sentinel_db
|
||||
monkeypatch.setitem(sys.modules, "hermes_state_registry", hermes_state_registry)
|
||||
|
||||
session_search_mod = ModuleType("tools.session_search_tool")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ suite (we patch ``agent.process_bootstrap.OpenAI`` and drive ``agent.client``),
|
||||
pass identically in CI and locally.
|
||||
"""
|
||||
|
||||
import hermes_state_errors
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
@@ -249,7 +250,7 @@ def test_classify_persistence_error_corruption_beats_disk_bucket():
|
||||
|
||||
|
||||
def test_classify_persistence_error_reuses_disk_full_markers():
|
||||
"""The disk bucket delegates to hermes_state.is_disk_full_error, so
|
||||
"""The disk bucket delegates to hermes_state_errors.is_disk_full_error, so
|
||||
every marker that helper recognizes (ENOSPC, 'not enough space', ...)
|
||||
must classify as 'disk' — the two classifiers can never drift apart."""
|
||||
import errno
|
||||
@@ -270,10 +271,8 @@ def test_classify_persistence_error_compression_busy_is_distinct():
|
||||
storage damage — but its message contains neither 'locked' nor 'busy',
|
||||
so it must classify by exception type (and by phrase for RPC-wrapped
|
||||
strings). This is the exact failure mode of issue #81227."""
|
||||
from hermes_state import (
|
||||
CompressionSessionBusyError,
|
||||
SessionCompressionInProgressError,
|
||||
)
|
||||
from hermes_state import SessionCompressionInProgressError
|
||||
from hermes_state_errors import CompressionSessionBusyError
|
||||
from hermes_state import classify_persistence_error
|
||||
|
||||
assert classify_persistence_error(
|
||||
@@ -294,7 +293,8 @@ def test_classify_persistence_error_compression_busy_is_distinct():
|
||||
|
||||
|
||||
def test_classify_persistence_error_turn_lease_lost_is_distinct():
|
||||
from hermes_state import SessionTurnLeaseLostError, classify_persistence_error
|
||||
from hermes_state import classify_persistence_error
|
||||
from hermes_state_errors import SessionTurnLeaseLostError
|
||||
|
||||
assert classify_persistence_error(
|
||||
SessionTurnLeaseLostError(
|
||||
@@ -309,7 +309,8 @@ def test_classify_persistence_error_turn_lease_lost_is_distinct():
|
||||
def test_persistence_error_causes_tuple_matches_classifier():
|
||||
"""PERSISTENCE_ERROR_CAUSES must cover every value the classifier can
|
||||
return (consumers like cron suppression iterate it)."""
|
||||
from hermes_state import PERSISTENCE_ERROR_CAUSES, classify_persistence_error
|
||||
from hermes_state import classify_persistence_error
|
||||
from hermes_state_errors import PERSISTENCE_ERROR_CAUSES
|
||||
|
||||
probes = (
|
||||
"database is locked",
|
||||
|
||||
@@ -11,7 +11,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB, CompressionSessionBusyError
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_errors import CompressionSessionBusyError
|
||||
|
||||
|
||||
def _setup_db(tmp_path):
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import errno
|
||||
import sqlite3
|
||||
|
||||
from hermes_state import is_disk_full_error
|
||||
from hermes_state_errors import is_disk_full_error
|
||||
|
||||
|
||||
def test_enospc_oserror():
|
||||
|
||||
@@ -27,7 +27,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state_common
|
||||
from hermes_state import FTS_STALE_KEY, SessionDB, _FTS_TRIGGERS
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_STALE_KEY, _FTS_TRIGGERS
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="POSIX flock child-process harness"
|
||||
@@ -338,9 +339,9 @@ class TestOrphanedHolderStalenessBreak:
|
||||
import os, sys, time
|
||||
sys.path.insert(0, {repo!r})
|
||||
from pathlib import Path
|
||||
import hermes_state
|
||||
import hermes_state_repair
|
||||
|
||||
lock_cm = hermes_state._cross_process_repair_lock(Path({db!r}))
|
||||
lock_cm = hermes_state_repair._cross_process_repair_lock(Path({db!r}))
|
||||
assert lock_cm.__enter__() is True
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
@@ -358,9 +359,9 @@ os._exit(1)
|
||||
grandchild = int(proc.stdout.readline().strip().split()[1])
|
||||
proc.wait(timeout=10)
|
||||
try:
|
||||
import hermes_state as hs
|
||||
import hermes_state_repair
|
||||
|
||||
with hs._cross_process_repair_lock(db_path) as holding:
|
||||
with hermes_state_repair._cross_process_repair_lock(db_path) as holding:
|
||||
assert holding is True
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
@@ -463,6 +464,7 @@ class TestNonContentionErrnoFailsFast:
|
||||
import fcntl
|
||||
|
||||
import hermes_state
|
||||
import hermes_state_repair
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_REPAIR_LOCK_TIMEOUT_SECONDS", 30.0)
|
||||
|
||||
@@ -471,7 +473,7 @@ class TestNonContentionErrnoFailsFast:
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", _flock)
|
||||
t0 = time.monotonic()
|
||||
with hermes_state._cross_process_repair_lock(tmp_path / "state.db") as ok:
|
||||
with hermes_state_repair._cross_process_repair_lock(tmp_path / "state.db") as ok:
|
||||
assert ok is False
|
||||
assert time.monotonic() - t0 < 2.0
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import FTS_TRIGRAM_SQL, SCHEMA_VERSION, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_TRIGRAM_SQL, SCHEMA_VERSION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -9,7 +9,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SCHEMA_VERSION, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import SCHEMA_VERSION
|
||||
from hermes_state_common import FTS_TRIGRAM_EXCLUDED_SOURCES, fts_trigram_session_sql
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
from hermes_state import SCHEMA_VERSION, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import SCHEMA_VERSION
|
||||
|
||||
|
||||
def _open_pair(tmp_path):
|
||||
|
||||
@@ -11,7 +11,8 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import SessionDB, SessionTurnLeaseLostError
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_errors import SessionTurnLeaseLostError
|
||||
|
||||
|
||||
def test_turn_lease_serializes_separate_session_db_instances(tmp_path):
|
||||
|
||||
@@ -5,7 +5,7 @@ work on a file several Hermes processes share (gateway service, the Desktop
|
||||
app's `hermes serve` backend, CLI sessions, the TUI slash worker):
|
||||
|
||||
* `hermes_state_common.fts_rebuild_admission` — full structural FTS rebuilds
|
||||
* `hermes_state._cross_process_repair_lock` — writable_schema surgery / VACUUM
|
||||
* `hermes_state_repair._cross_process_repair_lock` — writable_schema surgery / VACUUM
|
||||
|
||||
Both document themselves as fail-closed, and both honoured that only for a
|
||||
*timed-out* acquire. When the lock file could not be `open()`ed at all they
|
||||
@@ -34,8 +34,10 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
import hermes_state_repair
|
||||
import hermes_state_common
|
||||
from hermes_state import SessionDB, repair_state_db_schema
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
|
||||
|
||||
def _make_unopenable(lock_path: Path) -> None:
|
||||
@@ -130,7 +132,7 @@ def test_repair_lock_fails_closed_when_lock_file_is_unopenable(tmp_path):
|
||||
db_path = tmp_path / "state.db"
|
||||
_make_unopenable(db_path.with_name(db_path.name + ".repair.lock"))
|
||||
|
||||
with hermes_state._cross_process_repair_lock(db_path) as holding:
|
||||
with hermes_state_repair._cross_process_repair_lock(db_path) as holding:
|
||||
assert holding is False
|
||||
|
||||
|
||||
@@ -145,7 +147,7 @@ def test_repair_skips_surgery_when_lock_file_is_unopenable(tmp_path):
|
||||
db_path = tmp_path / "state.db"
|
||||
_build_healthy_db(db_path)
|
||||
_corrupt_duplicate_fts(db_path)
|
||||
assert hermes_state._db_opens_cleanly(db_path) is not None
|
||||
assert hermes_state_repair._db_opens_cleanly(db_path) is not None
|
||||
before = db_path.read_bytes()
|
||||
|
||||
_make_unopenable(db_path.with_name(db_path.name + ".repair.lock"))
|
||||
|
||||
@@ -12,7 +12,8 @@ import sqlite3
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import repair_state_db_schema
|
||||
import hermes_state_repair
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
|
||||
|
||||
def _make_db(path):
|
||||
@@ -31,9 +32,9 @@ def test_wal_restoration_reuses_exclusive_repair_connection(tmp_path, monkeypatc
|
||||
def fail_if_reopened(_path):
|
||||
pytest.fail("WAL restoration reopened state.db outside the repair guard")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_connect_repair_durable", fail_if_reopened)
|
||||
monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", fail_if_reopened)
|
||||
|
||||
hermes_state._restore_journal_mode_after_repair(db_path, None, conn=conn)
|
||||
hermes_state_repair._restore_journal_mode_after_repair(db_path, None, conn=conn)
|
||||
# The mode itself is whatever apply_wal_with_fallback resolves on this
|
||||
# runtime (WAL, or DELETE on WAL-reset-vulnerable SQLite builds); the
|
||||
# contract under test is the connection reuse, asserted above.
|
||||
@@ -46,20 +47,20 @@ def test_repair_never_reopens_after_the_guard_releases(tmp_path, monkeypatch):
|
||||
opens is opened while the exclusive guard is still held, and none after."""
|
||||
db = tmp_path / "state.db"
|
||||
_make_db(db)
|
||||
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", lambda path: "forced-unhealthy")
|
||||
monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", lambda path: "forced-unhealthy")
|
||||
# The scratch-space pre-flight wants ~10GB headroom; irrelevant here.
|
||||
monkeypatch.setattr(hermes_state, "_repair_scratch_space_error", lambda path: None)
|
||||
monkeypatch.setattr(hermes_state_repair, "_repair_scratch_space_error", lambda path: None)
|
||||
|
||||
def fake_strategies(scratch_path, report):
|
||||
report["repaired"] = True
|
||||
report["strategy"] = "test_strategy"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies)
|
||||
|
||||
events: list[str] = []
|
||||
real_guard = hermes_state._exclusive_repair_db_guard
|
||||
real_connect = hermes_state._connect_repair_durable
|
||||
real_guard = hermes_state_repair._exclusive_repair_db_guard
|
||||
real_connect = hermes_state_repair._connect_repair_durable
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
@@ -74,8 +75,8 @@ def test_repair_never_reopens_after_the_guard_releases(tmp_path, monkeypatch):
|
||||
events.append("connect")
|
||||
return real_connect(path, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_exclusive_repair_db_guard", tracing_guard)
|
||||
monkeypatch.setattr(hermes_state, "_connect_repair_durable", tracing_connect)
|
||||
monkeypatch.setattr(hermes_state_repair, "_exclusive_repair_db_guard", tracing_guard)
|
||||
monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", tracing_connect)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
assert report["repaired"] is True
|
||||
|
||||
@@ -14,7 +14,7 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import is_sqlite_wal_reset_vulnerable
|
||||
from hermes_state_wal import is_sqlite_wal_reset_vulnerable
|
||||
from tests.conftest import _wal_is_usable
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import FTS_CJK_STALE_KEY, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_CJK_STALE_KEY
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "native" / "fts5_cjk" / "fts5_cjk.c"
|
||||
@@ -149,7 +150,7 @@ def test_legacy_v22_optimize_lands_on_cjk(cjk_so, tmp_path, monkeypatch):
|
||||
cjk index in the same run."""
|
||||
import time as _time
|
||||
|
||||
from hermes_state import SCHEMA_SQL
|
||||
from hermes_state_common import SCHEMA_SQL
|
||||
|
||||
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
|
||||
db_path = tmp_path / "state.db"
|
||||
|
||||
@@ -12,7 +12,7 @@ import sqlite3
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import apply_durability_barriers
|
||||
from hermes_state_repair import apply_durability_barriers
|
||||
|
||||
|
||||
def _config(monkeypatch, database_section):
|
||||
|
||||
+32
-26
@@ -10,14 +10,21 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from agent.session_activity import ActivityProvenance
|
||||
from hermes_state import (
|
||||
FTS_SQL,
|
||||
FTS_STORAGE_VERSION,
|
||||
SCHEMA_SQL,
|
||||
SCHEMA_VERSION,
|
||||
SessionDB,
|
||||
)
|
||||
import hermes_state_wal
|
||||
import hermes_state_common
|
||||
from agent.session_activity import ActivityProvenance, build_activity_snapshot
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import FTS_SQL, FTS_STORAGE_VERSION, SCHEMA_SQL, SCHEMA_VERSION
|
||||
|
||||
|
||||
def _activity_snapshot(db, session_id):
|
||||
"""Durable activity snapshot for *session_id* (what gateway/delegate readers build from the row)."""
|
||||
row = db.get_session(session_id)
|
||||
return build_activity_snapshot(
|
||||
last_activity_at=row.get("last_activity_at"),
|
||||
last_activity_description=row.get("last_activity_description"),
|
||||
last_activity_provenance=row.get("last_activity_provenance"),
|
||||
)
|
||||
|
||||
|
||||
class _NoFtsCursor(sqlite3.Cursor):
|
||||
@@ -1741,7 +1748,7 @@ class TestSanitizeTitle:
|
||||
class TestSchemaInit:
|
||||
def test_wal_mode(self, db):
|
||||
"""Prefer WAL on fixed SQLite; DELETE on WAL-reset-vulnerable builds (#69784)."""
|
||||
from hermes_state import is_sqlite_wal_reset_vulnerable
|
||||
from hermes_state_wal import is_sqlite_wal_reset_vulnerable
|
||||
|
||||
cursor = db._conn.execute("PRAGMA journal_mode")
|
||||
mode = cursor.fetchone()[0].lower()
|
||||
@@ -1796,7 +1803,7 @@ class TestSchemaInit:
|
||||
This is the architectural invariant: SCHEMA_SQL declares the
|
||||
desired schema, _reconcile_columns ensures it matches reality.
|
||||
"""
|
||||
from hermes_state import SCHEMA_SQL
|
||||
from hermes_state_common import SCHEMA_SQL
|
||||
|
||||
expected = SessionDB._parse_schema_columns(SCHEMA_SQL)
|
||||
for table_name, declared_cols in expected.items():
|
||||
@@ -2375,7 +2382,7 @@ class TestListSessionsRich:
|
||||
assert row["last_activity_description"] == "starting API call #1"
|
||||
assert row["last_activity_provenance"] == "unknown"
|
||||
|
||||
activity = db.get_session_activity("s1")
|
||||
activity = _activity_snapshot(db, "s1")
|
||||
assert activity["last_activity_at"] == heartbeat
|
||||
assert activity["last_activity_description"] == "starting API call #1"
|
||||
assert "phase" not in activity
|
||||
@@ -2405,7 +2412,7 @@ class TestListSessionsRich:
|
||||
assert row["last_activity_at"] == heartbeat
|
||||
assert row["last_activity_description"] == ""
|
||||
assert row["last_activity_provenance"] == "unknown"
|
||||
activity = db.get_session_activity("s1")
|
||||
activity = _activity_snapshot(db, "s1")
|
||||
assert activity["last_activity_at"] == heartbeat
|
||||
assert activity["last_activity_description"] == ""
|
||||
assert activity["last_activity_provenance"] == "unknown"
|
||||
@@ -2448,7 +2455,7 @@ class TestListSessionsRich:
|
||||
rows = db.list_gateway_sessions(active_only=True)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["last_active"] == heartbeat
|
||||
activity = db.get_session_activity("gw-1")
|
||||
activity = _activity_snapshot(db, "gw-1")
|
||||
assert activity["last_activity_description"] == "compressing context"
|
||||
|
||||
def test_order_by_last_active_surfaces_recently_touched_older_session_first(self, db):
|
||||
@@ -3108,7 +3115,7 @@ class TestVacuum:
|
||||
|
||||
def test_auto_maintenance_freelist_ratio_exactly_at_threshold_skips(self, db, monkeypatch):
|
||||
"""Gate is strictly greater-than: 25.0% reclaimable does not VACUUM."""
|
||||
from hermes_state import AUTO_VACUUM_MIN_FREELIST_RATIO
|
||||
from hermes_state_common import AUTO_VACUUM_MIN_FREELIST_RATIO
|
||||
|
||||
monkeypatch.setattr(db, "prune_sessions", lambda **_kwargs: 1)
|
||||
monkeypatch.setattr(db, "_freelist_ratio", lambda: AUTO_VACUUM_MIN_FREELIST_RATIO)
|
||||
@@ -3148,7 +3155,7 @@ class TestVacuum:
|
||||
|
||||
def test_freelist_ratio_reads_real_pragmas(self, db):
|
||||
"""Real-DB check: freeing most of the file pushes the ratio past the gate."""
|
||||
from hermes_state import AUTO_VACUUM_MIN_FREELIST_RATIO
|
||||
from hermes_state_common import AUTO_VACUUM_MIN_FREELIST_RATIO
|
||||
|
||||
db.create_session(session_id="keep", source="cli")
|
||||
db.append_message(session_id="keep", role="user", content="hi")
|
||||
@@ -3534,7 +3541,7 @@ class TestFTS5ToolCallMigration:
|
||||
assert len(session_db.search_messages("LEGACYARG")) == 1, \
|
||||
"v23 optimize must index tool_calls JSON into FTS"
|
||||
# schema_version bumped once the FTS layer is v23
|
||||
from hermes_state import SCHEMA_VERSION
|
||||
from hermes_state_common import SCHEMA_VERSION
|
||||
row = session_db._conn.execute(
|
||||
"SELECT version FROM schema_version LIMIT 1"
|
||||
).fetchone()
|
||||
@@ -3806,7 +3813,7 @@ class TestFTSExternalContentMigration:
|
||||
Mirrors what happened when ``_ensure_fts_schema`` ran inside
|
||||
``_execute_write`` and the process died before the marker writes.
|
||||
"""
|
||||
from hermes_state import FTS_SQL, FTS_TRIGRAM_SQL
|
||||
from hermes_state_common import FTS_SQL, FTS_TRIGRAM_SQL
|
||||
|
||||
conn = db._conn
|
||||
db._drop_fts_triggers(conn)
|
||||
@@ -3871,7 +3878,7 @@ class TestFTSExternalContentMigration:
|
||||
assert db.fts_rebuild_status() is None
|
||||
assert db.fts_optimize_available() is False
|
||||
assert db.get_meta("fts_storage_version") == str(
|
||||
hermes_state.FTS_STORAGE_VERSION
|
||||
hermes_state_common.FTS_STORAGE_VERSION
|
||||
)
|
||||
assert db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE name LIKE '%_v22_trash%'"
|
||||
@@ -3913,7 +3920,7 @@ class TestFTSExternalContentMigration:
|
||||
"INSERT INTO state_meta (key, value) VALUES "
|
||||
"('fts_storage_version', ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(str(hermes_state.FTS_STORAGE_VERSION),),
|
||||
(str(hermes_state_common.FTS_STORAGE_VERSION),),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
@@ -3928,7 +3935,7 @@ class TestFTSExternalContentMigration:
|
||||
assert result["ok"] is True
|
||||
assert len(db.search_messages("deployment")) == 1
|
||||
assert db.get_meta("fts_storage_version") == str(
|
||||
hermes_state.FTS_STORAGE_VERSION
|
||||
hermes_state_common.FTS_STORAGE_VERSION
|
||||
)
|
||||
assert db.fts_optimize_available() is False
|
||||
finally:
|
||||
@@ -4275,14 +4282,14 @@ class TestApplyWalProbe:
|
||||
import hermes_state
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
)
|
||||
|
||||
|
||||
def test_sets_wal_on_fresh_connection(self, tmp_path):
|
||||
"""Probe sees 'delete', then set-pragma runs and returns 'wal'."""
|
||||
import sqlite3
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
class _TracingConn(sqlite3.Connection):
|
||||
def __init__(self, *a, **kw):
|
||||
@@ -4315,7 +4322,7 @@ class TestApplyWalProbe:
|
||||
import sys
|
||||
import threading
|
||||
import sqlite3
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
db_path = tmp_path / "concurrent.db"
|
||||
errors = []
|
||||
@@ -4361,7 +4368,7 @@ class TestApplyWalProbe:
|
||||
def test_returns_wal_not_delete_from_probe(self, tmp_path):
|
||||
"""Early-return only on 'wal'; 'delete' or 'memory' must fall through to set-pragma."""
|
||||
import sqlite3
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
class _TracingConn(sqlite3.Connection):
|
||||
def __init__(self, *a, **kw):
|
||||
@@ -5699,8 +5706,7 @@ class TestPerformancePragmasEndToEnd:
|
||||
# path. Force WAL eligibility so _get_read_conn is truly exercised
|
||||
# (established pattern used by the WAL tests above).
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"is_sqlite_wal_reset_vulnerable",
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
|
||||
lambda version_info=None: False,
|
||||
)
|
||||
home = tmp_path / "hermes_home"
|
||||
|
||||
@@ -19,13 +19,9 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
SessionDB,
|
||||
WalUnsupportedError,
|
||||
apply_wal_with_fallback,
|
||||
format_session_db_unavailable,
|
||||
get_last_init_error,
|
||||
)
|
||||
import hermes_state_wal
|
||||
from hermes_state import SessionDB, format_session_db_unavailable, get_last_init_error
|
||||
from hermes_state_wal import WalUnsupportedError, apply_wal_with_fallback
|
||||
|
||||
|
||||
# ``sqlite3.Connection.execute`` is a C-level slot and can't be monkeypatched
|
||||
@@ -88,20 +84,20 @@ def _reset_last_init_error():
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_wal_fallback_warned_paths():
|
||||
"""Reset the WAL-fallback warned-paths set so dedup doesn't leak between tests."""
|
||||
hermes_state._wal_fallback_warned_paths.clear()
|
||||
hermes_state_wal._wal_fallback_warned_paths.clear()
|
||||
yield
|
||||
hermes_state._wal_fallback_warned_paths.clear()
|
||||
hermes_state_wal._wal_fallback_warned_paths.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _assume_fixed_sqlite(monkeypatch):
|
||||
"""NFS-fallback tests assume a SQLite build without the WAL-reset bug."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
||||
)
|
||||
hermes_state._wal_reset_bug_warned_paths.clear()
|
||||
hermes_state_wal._wal_reset_bug_warned_paths.clear()
|
||||
yield
|
||||
hermes_state._wal_reset_bug_warned_paths.clear()
|
||||
hermes_state_wal._wal_reset_bug_warned_paths.clear()
|
||||
|
||||
|
||||
class TestApplyWalWithFallback:
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state_wal
|
||||
import yaml
|
||||
|
||||
|
||||
@@ -24,7 +26,7 @@ def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) ->
|
||||
|
||||
def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: False,
|
||||
)
|
||||
|
||||
@@ -35,9 +37,9 @@ def _reset_configured_delete_override_warned_paths():
|
||||
once-per-process-per-db_label dedup doesn't leak between tests."""
|
||||
import hermes_state
|
||||
|
||||
hermes_state._delete_overridden_warned_paths.clear()
|
||||
hermes_state_wal._delete_overridden_warned_paths.clear()
|
||||
yield
|
||||
hermes_state._delete_overridden_warned_paths.clear()
|
||||
hermes_state_wal._delete_overridden_warned_paths.clear()
|
||||
|
||||
|
||||
def test_database_journal_mode_has_a_canonical_default():
|
||||
@@ -47,14 +49,14 @@ def test_database_journal_mode_has_a_canonical_default():
|
||||
|
||||
|
||||
def test_resolve_journal_mode_uses_real_database_config(monkeypatch, tmp_path):
|
||||
from hermes_state import resolve_journal_mode
|
||||
from hermes_state_wal import resolve_journal_mode
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "DELETE")
|
||||
assert resolve_journal_mode() == "delete"
|
||||
|
||||
|
||||
def test_new_nonsecret_hermes_env_override_is_not_exposed(monkeypatch, tmp_path):
|
||||
from hermes_state import resolve_journal_mode
|
||||
from hermes_state_wal import resolve_journal_mode
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
monkeypatch.setenv("HERMES_JOURNAL_MODE", "delete")
|
||||
@@ -63,7 +65,7 @@ def test_new_nonsecret_hermes_env_override_is_not_exposed(monkeypatch, tmp_path)
|
||||
|
||||
@pytest.mark.parametrize("value", ["bogus", "truncate", None, 42, {"bad": "shape"}])
|
||||
def test_invalid_config_value_falls_back_to_wal(monkeypatch, tmp_path, value):
|
||||
from hermes_state import resolve_journal_mode
|
||||
from hermes_state_wal import resolve_journal_mode
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, value)
|
||||
assert resolve_journal_mode() == "wal"
|
||||
@@ -73,14 +75,14 @@ def test_invalid_config_value_falls_back_to_wal(monkeypatch, tmp_path, value):
|
||||
def test_malformed_database_section_falls_back_to_wal(
|
||||
monkeypatch, tmp_path, database
|
||||
):
|
||||
from hermes_state import resolve_journal_mode
|
||||
from hermes_state_wal import resolve_journal_mode
|
||||
|
||||
_write_config(monkeypatch, tmp_path, {"database": database})
|
||||
assert resolve_journal_mode() == "wal"
|
||||
|
||||
|
||||
def test_apply_wal_with_fallback_honors_delete_config(monkeypatch, tmp_path):
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -93,7 +95,7 @@ def test_apply_wal_with_fallback_honors_delete_config(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_apply_wal_with_fallback_defaults_to_wal(monkeypatch, tmp_path):
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -107,11 +109,11 @@ def test_apply_wal_with_fallback_defaults_to_wal(monkeypatch, tmp_path):
|
||||
|
||||
def test_configured_delete_validates_vulnerable_sqlite_result(monkeypatch, tmp_path):
|
||||
"""The safety gate must not report DELETE when SQLite returns MEMORY."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: True,
|
||||
)
|
||||
conn = sqlite3.connect(":memory:")
|
||||
@@ -127,7 +129,7 @@ def test_configured_delete_never_live_downgrades_existing_wal(monkeypatch, tmp_p
|
||||
"""Keeping WAL is correct, but the operator must be told their configured
|
||||
delete had no effect (otherwise the DB silently stays WAL and the protection
|
||||
they configured never applies)."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
db_path = tmp_path / "existing-wal.db"
|
||||
@@ -135,7 +137,7 @@ def test_configured_delete_never_live_downgrades_existing_wal(monkeypatch, tmp_p
|
||||
try:
|
||||
assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal"
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: True,
|
||||
)
|
||||
with caplog.at_level("ERROR", logger="hermes_state"):
|
||||
@@ -155,7 +157,7 @@ def test_configured_delete_overridden_warns_on_non_vulnerable_runtime_too(monkey
|
||||
3.51.3+ upgrade), the on-disk WAL + configured-delete case reaches the
|
||||
read-only probe path instead of the vulnerability path. That path used to
|
||||
return WAL with no signal at all; it must emit the same override warning."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -182,11 +184,11 @@ def test_configured_delete_overridden_warns_on_non_vulnerable_runtime_too(monkey
|
||||
def test_configured_delete_overridden_warning_fires_once_per_db(monkeypatch, tmp_path, caplog):
|
||||
"""The override warning is deduped per process per db_label (same discipline
|
||||
as the WAL-fallback warning), so repeated connections don't flood the log."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: True,
|
||||
)
|
||||
db_path = tmp_path / "existing-wal.db"
|
||||
@@ -211,7 +213,7 @@ def test_configured_delete_with_require_wal_and_existing_wal_returns_wal(monkeyp
|
||||
existing-WAL probe branch returns "wal" unconditionally (require_wal only
|
||||
governs the WAL-refusal fallback paths), so the override warning fires and no
|
||||
WalUnsupportedError is raised."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -242,6 +244,7 @@ def test_real_db_openers_honor_configured_delete(monkeypatch, tmp_path):
|
||||
from gateway import delivery_ledger
|
||||
from gateway.platforms.api_server import ResponseStore
|
||||
from hermes_cli import kanban_db, projects_db
|
||||
from hermes_cli import kanban_db_connect as kbc
|
||||
from hermes_state import SessionDB
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
from plugins.platforms.discord.recovery import DiscordRecoveryStore
|
||||
|
||||
@@ -26,6 +26,8 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_state_wal
|
||||
import yaml
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) ->
|
||||
|
||||
def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: False,
|
||||
)
|
||||
|
||||
@@ -64,16 +66,16 @@ def _reset_dedup():
|
||||
"""Order-independence: the warning is deduped per process per db_label."""
|
||||
import hermes_state
|
||||
|
||||
hermes_state._journal_upgrade_warned_paths.clear()
|
||||
hermes_state_wal._journal_upgrade_warned_paths.clear()
|
||||
yield
|
||||
hermes_state._journal_upgrade_warned_paths.clear()
|
||||
hermes_state_wal._journal_upgrade_warned_paths.clear()
|
||||
|
||||
|
||||
class TestTheContentProbe:
|
||||
"""``_database_has_content`` is what keeps fresh installs quiet."""
|
||||
|
||||
def test_a_brand_new_database_has_no_content(self, tmp_path):
|
||||
from hermes_state import _database_has_content
|
||||
from hermes_state_wal import _database_has_content
|
||||
|
||||
conn = sqlite3.connect(str(tmp_path / "new.db"))
|
||||
try:
|
||||
@@ -82,7 +84,7 @@ class TestTheContentProbe:
|
||||
conn.close()
|
||||
|
||||
def test_a_database_with_a_table_has_content(self, tmp_path):
|
||||
from hermes_state import _database_has_content
|
||||
from hermes_state_wal import _database_has_content
|
||||
|
||||
path = tmp_path / "used.db"
|
||||
_make_delete_db_with_content(path)
|
||||
@@ -98,7 +100,7 @@ class TestTheContentProbe:
|
||||
Answering True on an error would emit the warning for a database we
|
||||
could not measure, which includes every fresh one.
|
||||
"""
|
||||
from hermes_state import _database_has_content
|
||||
from hermes_state_wal import _database_has_content
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
@@ -113,7 +115,7 @@ class TestTheWarningFires:
|
||||
def test_an_existing_delete_database_warns_when_flipped(
|
||||
self, monkeypatch, tmp_path, caplog
|
||||
):
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -139,7 +141,7 @@ class TestTheWarningFires:
|
||||
Telling an operator their mode changed, without telling them which
|
||||
lever survives an open, leaves them doing the same PRAGMA again.
|
||||
"""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -165,7 +167,7 @@ class TestTheWarningFires:
|
||||
(managed_uv repairs it on update, citing ~2600x slower appends), so
|
||||
this must warn about the change without preventing it.
|
||||
"""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -183,7 +185,7 @@ class TestTheWarningFires:
|
||||
self, monkeypatch, tmp_path, caplog
|
||||
):
|
||||
"""kanban opens a connection per operation; undeduped this is a flood."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -205,7 +207,7 @@ class TestTheWarningFires:
|
||||
self, monkeypatch, tmp_path, caplog
|
||||
):
|
||||
"""#89293 saw four databases flip. Dedup is per label, not global."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -236,7 +238,7 @@ class TestTheWarningStaysQuiet:
|
||||
every opener applies WAL before creating any schema -- so without
|
||||
this guard the warning fires on every first run of every install.
|
||||
"""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -252,7 +254,7 @@ class TestTheWarningStaysQuiet:
|
||||
|
||||
def test_an_existing_wal_database_is_silent(self, monkeypatch, tmp_path, caplog):
|
||||
"""No flip happens: the probe returns early. Nothing to report."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -271,7 +273,7 @@ class TestTheWarningStaysQuiet:
|
||||
|
||||
def test_configured_delete_is_silent(self, monkeypatch, tmp_path, caplog):
|
||||
"""The operator used the durable lever. There is nothing to tell them."""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
@@ -299,11 +301,11 @@ class TestTheWarningStaysQuiet:
|
||||
the SQLite upgrade -- warning here would blame the guard that was
|
||||
doing its job.
|
||||
"""
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: True,
|
||||
)
|
||||
path = tmp_path / "vulnerable.db"
|
||||
@@ -327,7 +329,7 @@ class TestTheExistingContractIsUnchanged:
|
||||
"""Behaviour preservation for the rules this change sits next to."""
|
||||
|
||||
def test_on_disk_wal_is_still_never_live_downgraded(self, monkeypatch, tmp_path):
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "delete")
|
||||
path = tmp_path / "existing-wal.db"
|
||||
@@ -335,7 +337,7 @@ class TestTheExistingContractIsUnchanged:
|
||||
try:
|
||||
assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal"
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
||||
"hermes_state_wal.is_sqlite_wal_reset_vulnerable",
|
||||
lambda **kwargs: True,
|
||||
)
|
||||
assert apply_wal_with_fallback(conn, db_label="existing-wal.db") == "wal"
|
||||
@@ -346,7 +348,7 @@ class TestTheExistingContractIsUnchanged:
|
||||
def test_default_config_still_yields_wal_on_a_fresh_database(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
from hermes_state_wal import apply_wal_with_fallback
|
||||
|
||||
_configure_mode(monkeypatch, tmp_path, "wal")
|
||||
_disable_vulnerable_gate(monkeypatch)
|
||||
|
||||
@@ -152,17 +152,17 @@ def test_reacting_never_mutates_message_content(session, db):
|
||||
def test_latest_user_message_is_the_agents_default_target(session, db):
|
||||
"""The agent reacts to "the message that triggered me" without an id."""
|
||||
key, rows = session
|
||||
assert db.latest_user_message_row_id(key) == rows[0]
|
||||
assert db.latest_message_row_id(key, role="user") == rows[0]
|
||||
|
||||
db.append_message(key, "user", "thanks!")
|
||||
newest = db.get_messages_as_conversation(key, include_row_ids=True)[-1]["_row_id"]
|
||||
|
||||
assert db.latest_user_message_row_id(key) == newest
|
||||
assert db.latest_message_row_id(key, role="user") == newest
|
||||
|
||||
# Role-targeting contract: a newer ASSISTANT message must not become the
|
||||
# agent's default target — it always means the latest USER message.
|
||||
db.append_message(key, "assistant", "you're welcome")
|
||||
assert db.latest_user_message_row_id(key) == newest
|
||||
assert db.latest_message_row_id(key, role="user") == newest
|
||||
assert db.latest_message_row_id(key, role="assistant") != newest
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ holds POSIX locks on that inode, so raw descriptor counts lag the real
|
||||
connection count and make such assertions flaky.
|
||||
"""
|
||||
|
||||
import hermes_state_readpool
|
||||
import queue
|
||||
import threading
|
||||
|
||||
@@ -540,10 +541,10 @@ def test_peak_is_bounded_across_many_database_files(tmp_path):
|
||||
finally:
|
||||
for d in dbs:
|
||||
d.close()
|
||||
assert hermes_state._process_read_permits.acquire(blocking=False), (
|
||||
assert hermes_state_readpool._process_read_permits.acquire(blocking=False), (
|
||||
"close() stranded a process permit"
|
||||
)
|
||||
hermes_state._process_read_permits.release()
|
||||
hermes_state_readpool._process_read_permits.release()
|
||||
|
||||
|
||||
@pytest.mark.requires_wal
|
||||
|
||||
@@ -8,7 +8,8 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SCHEMA_VERSION, SessionDB
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_common import SCHEMA_VERSION
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Session <-> workspace grouping key (hermes_state.workspace_key).
|
||||
"""Session <-> workspace grouping key (hermes_state_sessions.workspace_key).
|
||||
|
||||
The key is what `hermes sessions list --workspace` groups/filters on. It is a
|
||||
coarse workspace identity derived from fields already recorded on sessions
|
||||
@@ -6,7 +6,8 @@ coarse workspace identity derived from fields already recorded on sessions
|
||||
NOT part of the key.
|
||||
"""
|
||||
|
||||
from hermes_state import workspace_key
|
||||
import hermes_state_sessions
|
||||
from hermes_state_sessions import workspace_key
|
||||
|
||||
|
||||
def test_repo_root_is_the_key_when_known():
|
||||
|
||||
@@ -18,18 +18,15 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
apply_wal_with_fallback,
|
||||
is_sqlite_wal_reset_vulnerable,
|
||||
sqlite_source_id,
|
||||
)
|
||||
import hermes_state_wal
|
||||
from hermes_state_wal import apply_wal_with_fallback, is_sqlite_wal_reset_vulnerable, sqlite_source_id
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_wal_reset_bug_warnings():
|
||||
hermes_state._wal_reset_bug_warned_paths.clear()
|
||||
hermes_state_wal._wal_reset_bug_warned_paths.clear()
|
||||
yield
|
||||
hermes_state._wal_reset_bug_warned_paths.clear()
|
||||
hermes_state_wal._wal_reset_bug_warned_paths.clear()
|
||||
|
||||
|
||||
class TestIsSqliteWalResetVulnerable:
|
||||
@@ -61,7 +58,7 @@ class TestIsSqliteWalResetVulnerable:
|
||||
class TestApplyWalWalResetGate:
|
||||
def test_fresh_db_uses_delete_when_vulnerable(self, tmp_path, monkeypatch, caplog):
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
conn = sqlite3.connect(str(tmp_path / "fresh.db"))
|
||||
with caplog.at_level("WARNING", logger="hermes_state"):
|
||||
@@ -77,7 +74,7 @@ class TestApplyWalWalResetGate:
|
||||
):
|
||||
"""Already-WAL DBs must not be live-downgraded under concurrent openers."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
path = tmp_path / "prior_wal.db"
|
||||
seed = sqlite3.connect(str(path))
|
||||
@@ -109,7 +106,7 @@ class TestApplyWalWalResetGate:
|
||||
|
||||
def test_warning_deduped_per_label(self, tmp_path, monkeypatch, caplog):
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
with caplog.at_level("WARNING", logger="hermes_state"):
|
||||
for name in ("a.db", "a.db", "b.db"):
|
||||
@@ -151,7 +148,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
|
||||
All blocked-state assertions run WHILE the holder owns the DB."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
db = tmp_path / "live_wal.db"
|
||||
seed = sqlite3.connect(str(db))
|
||||
@@ -215,7 +212,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
as 'not WAL' and flipping anyway (the incident's exact confusion).
|
||||
Assertions run WHILE the holder's exclusive lock is live."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
db = tmp_path / "locked_wal.db"
|
||||
seed = sqlite3.connect(str(db))
|
||||
@@ -266,7 +263,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
"""No concurrent openers → the vulnerable-SQLite DELETE gate still
|
||||
applies exactly as before."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
conn = sqlite3.connect(str(tmp_path / "exclusive.db"))
|
||||
try:
|
||||
@@ -287,7 +284,7 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
between probe and flip), the gate returns the observed mode instead of
|
||||
raising or waiting the lock out."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
|
||||
class _FlipLockedConnection(sqlite3.Connection):
|
||||
@@ -316,11 +313,10 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
refuse to downgrade when the mode probe is blocked by a concurrent
|
||||
opener's exclusive lock — raise, never flip blind."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"is_sqlite_wal_reset_vulnerable",
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
|
||||
lambda version_info=None: False,
|
||||
)
|
||||
monkeypatch.setattr(hermes_state, "resolve_journal_mode", lambda: "delete")
|
||||
monkeypatch.setattr(hermes_state_wal, "resolve_journal_mode", lambda: "delete")
|
||||
db = tmp_path / "cfg_delete.db"
|
||||
seed = sqlite3.connect(str(db))
|
||||
try:
|
||||
@@ -358,11 +354,10 @@ class TestNoDowngradeUnderConcurrentOpeners:
|
||||
"""The filesystem-incompat fallback must not downgrade when the on-disk
|
||||
mode cannot be verified (possible concurrent openers)."""
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"is_sqlite_wal_reset_vulnerable",
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable",
|
||||
lambda version_info=None: False,
|
||||
)
|
||||
hermes_state._wal_fallback_warned_paths.clear()
|
||||
hermes_state_wal._wal_fallback_warned_paths.clear()
|
||||
|
||||
class _LockedProbeConnection(sqlite3.Connection):
|
||||
def execute(self, sql, *args, **kwargs): # type: ignore[override]
|
||||
@@ -394,9 +389,9 @@ def test_doctor_warns_without_adding_issues(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
||||
)
|
||||
monkeypatch.setattr(hermes_state, "sqlite_source_id", lambda: "testid-abc")
|
||||
monkeypatch.setattr(hermes_state_wal, "sqlite_source_id", lambda: "testid-abc")
|
||||
monkeypatch.setattr(sqlite3, "sqlite_version", "3.50.4", raising=False)
|
||||
|
||||
args = SimpleNamespace(fix=False, ack=None)
|
||||
|
||||
@@ -23,11 +23,10 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
SessionDB,
|
||||
is_malformed_db_error,
|
||||
repair_state_db_schema,
|
||||
)
|
||||
import hermes_state_repair
|
||||
import hermes_state_wal
|
||||
from hermes_state import SessionDB, is_malformed_db_error
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
|
||||
|
||||
def _build_healthy_db(db_path: Path) -> str:
|
||||
@@ -80,10 +79,9 @@ def test_generic_malformed_open_does_not_attempt_schema_surgery(
|
||||
def _generic_corruption(*_args, **_kwargs):
|
||||
raise sqlite3.DatabaseError("database disk image is malformed")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", _generic_corruption)
|
||||
monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", _generic_corruption) # SessionDB open path
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"repair_state_db_schema",
|
||||
hermes_state, "repair_state_db_schema",
|
||||
lambda *args, **kwargs: repair_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
@@ -188,7 +186,7 @@ def _corrupt_fts_shadow_segments(db_path: Path) -> None:
|
||||
|
||||
def test_fts_read_corruption_repaired_in_place(tmp_path):
|
||||
"""``repair_state_db_schema`` rebuilds the FTS index so reads resume."""
|
||||
from hermes_state import _db_opens_cleanly
|
||||
from hermes_state_repair import _db_opens_cleanly
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
_build_healthy_db(db_path)
|
||||
@@ -275,7 +273,7 @@ def _corrupt_fts_index_data(db_path: Path) -> None:
|
||||
|
||||
def test_fts_write_corruption_detected_by_write_probe(tmp_path):
|
||||
"""_db_opens_cleanly's rolled-back write probe flags FTS write corruption."""
|
||||
from hermes_state import _db_opens_cleanly
|
||||
from hermes_state_repair import _db_opens_cleanly
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
_build_healthy_db(db_path)
|
||||
@@ -296,7 +294,7 @@ def test_fts_write_corruption_detected_by_write_probe(tmp_path):
|
||||
|
||||
def test_fts_write_corruption_repaired_in_place(tmp_path):
|
||||
"""repair_state_db_schema rebuilds the FTS index; reads + writes resume."""
|
||||
from hermes_state import _db_opens_cleanly
|
||||
from hermes_state_repair import _db_opens_cleanly
|
||||
|
||||
db_path = tmp_path / "state.db"
|
||||
_build_healthy_db(db_path)
|
||||
@@ -380,7 +378,7 @@ def test_repair_rebuilds_stale_btree_indexes(tmp_path):
|
||||
_corrupt_btree_index(db_path, "idx_messages_session")
|
||||
|
||||
# The real detector must see the real corruption...
|
||||
reason = hermes_state._db_opens_cleanly(db_path)
|
||||
reason = hermes_state_repair._db_opens_cleanly(db_path)
|
||||
assert reason is not None
|
||||
assert "wrong # of entries in index idx_messages_session" in reason
|
||||
|
||||
@@ -391,7 +389,7 @@ def test_repair_rebuilds_stale_btree_indexes(tmp_path):
|
||||
|
||||
# Post-repair the DB is genuinely healthy: detector and raw
|
||||
# integrity_check both agree, and the repaired index answers queries.
|
||||
assert hermes_state._db_opens_cleanly(db_path) is None
|
||||
assert hermes_state_repair._db_opens_cleanly(db_path) is None
|
||||
raw = sqlite3.connect(str(db_path))
|
||||
assert raw.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
|
||||
n = raw.execute(
|
||||
@@ -481,7 +479,7 @@ def test_repair_skips_surgery_while_another_process_holds_the_lock(
|
||||
# No surgery ran: no backup was taken and the DB is still malformed.
|
||||
assert report["backup_path"] is None
|
||||
assert not list(tmp_path.glob("state.db.malformed-backup-*"))
|
||||
assert hermes_state._db_opens_cleanly(db_path) is not None
|
||||
assert hermes_state_repair._db_opens_cleanly(db_path) is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX flock test")
|
||||
@@ -503,7 +501,7 @@ def test_repair_reports_success_when_the_holder_already_healed_the_db(
|
||||
_REPAIR_SCRIPT = """
|
||||
import sys, json
|
||||
sys.path.insert(0, {root!r})
|
||||
from hermes_state import repair_state_db_schema
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
print(json.dumps(repair_state_db_schema({db!r})), flush=True)
|
||||
"""
|
||||
|
||||
@@ -597,8 +595,7 @@ def test_backup_refusal_hard_stops_the_repair(tmp_path, monkeypatch):
|
||||
original_bytes = db_path.read_bytes()
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"_backup_db_file",
|
||||
hermes_state_repair, "_backup_db_file",
|
||||
lambda p: (None, "a connection to it is still open in this process"),
|
||||
)
|
||||
|
||||
@@ -610,7 +607,7 @@ def test_backup_refusal_hard_stops_the_repair(tmp_path, monkeypatch):
|
||||
assert "still open" in report["error"]
|
||||
# No mutating strategy ran: the damaged source bytes are untouched.
|
||||
assert db_path.read_bytes() == original_bytes
|
||||
assert hermes_state._db_opens_cleanly(db_path) is not None
|
||||
assert hermes_state_repair._db_opens_cleanly(db_path) is not None
|
||||
|
||||
|
||||
def test_backup_copy_failure_hard_stops_the_repair(tmp_path, monkeypatch):
|
||||
@@ -620,8 +617,7 @@ def test_backup_copy_failure_hard_stops_the_repair(tmp_path, monkeypatch):
|
||||
_corrupt_duplicate_fts(db_path)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"_backup_db_file",
|
||||
hermes_state_repair, "_backup_db_file",
|
||||
lambda p: (None, "backup copy failed: [Errno 28] No space left on device"),
|
||||
)
|
||||
|
||||
@@ -671,7 +667,7 @@ def _configure_journal_mode(monkeypatch, tmp_path, mode) -> None:
|
||||
yaml.safe_dump({"database": {"journal_mode": mode}}), encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: False,
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: False,
|
||||
)
|
||||
|
||||
|
||||
@@ -722,7 +718,7 @@ def test_repair_restore_matches_canonical_on_vulnerable_sqlite(
|
||||
db_path = tmp_path / "state.db"
|
||||
_configure_journal_mode(monkeypatch, tmp_path, "wal")
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: True
|
||||
hermes_state_wal, "is_sqlite_wal_reset_vulnerable", lambda **kwargs: True
|
||||
)
|
||||
_build_healthy_db(db_path)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
@@ -756,7 +752,7 @@ def test_repair_logs_mode_change_when_probe_succeeded(
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hermes_state, "_probe_journal_mode_for_repair", return_value="delete"
|
||||
hermes_state_repair, "_probe_journal_mode_for_repair", return_value="delete"
|
||||
),
|
||||
caplog.at_level(logging.WARNING, logger="hermes_state"),
|
||||
):
|
||||
@@ -813,7 +809,7 @@ def test_repair_restore_failure_is_nonfatal_and_logged(
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
with (
|
||||
patch.object(hermes_state, "apply_wal_with_fallback", _refused),
|
||||
patch.object(hermes_state_wal, "apply_wal_with_fallback", _refused),
|
||||
caplog.at_level(logging.WARNING, logger="hermes_state"),
|
||||
):
|
||||
report = repair_state_db_schema(db_path)
|
||||
|
||||
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB, StateDbCorruptError, _on_disk_journal_mode
|
||||
from hermes_state import SessionDB, StateDbCorruptError
|
||||
from hermes_state_wal import _on_disk_journal_mode
|
||||
|
||||
|
||||
class _NotADbOnce:
|
||||
|
||||
@@ -29,11 +29,10 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
import hermes_state_repair
|
||||
import hermes_state_holders
|
||||
from hermes_state import (
|
||||
SessionDB,
|
||||
repair_state_db_schema,
|
||||
)
|
||||
from hermes_state import SessionDB
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
|
||||
|
||||
def _make_wal_db(tmp_path: Path) -> Path:
|
||||
@@ -101,7 +100,7 @@ def test_repair_checks_foreign_holders_before_opening_sqlite(tmp_path, monkeypat
|
||||
def _unexpected_probe(*_args, **_kwargs):
|
||||
pytest.fail("repair opened SQLite before excluding foreign holders")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe)
|
||||
monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
@@ -181,7 +180,7 @@ def test_uninspectable_watched_descriptor_blocks_repair_before_sqlite(
|
||||
def _unexpected_probe(*_args, **_kwargs):
|
||||
pytest.fail("repair opened SQLite with unproven descriptor identity")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe)
|
||||
monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
@@ -227,8 +226,7 @@ def test_uninspectable_unknown_descriptor_uses_hermes_identity_at_repair_boundar
|
||||
pytest.fail("repair opened SQLite with an unproven Hermes descriptor")
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"_connect_repair_durable",
|
||||
hermes_state_repair, "_connect_repair_durable",
|
||||
_unexpected_probe,
|
||||
)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
@@ -236,7 +234,7 @@ def test_uninspectable_unknown_descriptor_uses_hermes_identity_at_repair_boundar
|
||||
assert report["repaired"] is False
|
||||
assert "live writer" in (report["error"] or "").lower()
|
||||
else:
|
||||
real_connect = hermes_state._connect_repair_durable
|
||||
real_connect = hermes_state_repair._connect_repair_durable
|
||||
probe_reached = False
|
||||
|
||||
def _record_probe(*args, **kwargs):
|
||||
@@ -245,8 +243,7 @@ def test_uninspectable_unknown_descriptor_uses_hermes_identity_at_repair_boundar
|
||||
return real_connect(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"_connect_repair_durable",
|
||||
hermes_state_repair, "_connect_repair_durable",
|
||||
_record_probe,
|
||||
)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
@@ -288,7 +285,7 @@ def test_uninspectable_watched_identity_blocks_alias_before_sqlite(
|
||||
def _unexpected_probe(*_args, **_kwargs):
|
||||
pytest.fail("repair opened SQLite with an unproven watched identity")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe)
|
||||
monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
@@ -332,7 +329,7 @@ def test_uninspectable_alias_descriptor_for_hermes_blocks_before_sqlite(
|
||||
def _unexpected_probe(*_args, **_kwargs):
|
||||
pytest.fail("repair opened SQLite with an unproven Hermes alias fd")
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_connect_repair_durable", _unexpected_probe)
|
||||
monkeypatch.setattr(hermes_state_repair, "_connect_repair_durable", _unexpected_probe)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
|
||||
@@ -25,17 +25,8 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
_MAX_MALFORMED_BACKUPS,
|
||||
_MAX_PERSISTENT_REPAIR_ATTEMPTS,
|
||||
_backup_db_file,
|
||||
_existing_malformed_backups,
|
||||
_persistent_repair_attempts_exhausted,
|
||||
_prune_malformed_backups,
|
||||
_record_repair_outcome,
|
||||
_repair_ledger_path,
|
||||
repair_state_db_schema,
|
||||
)
|
||||
import hermes_state_repair
|
||||
from hermes_state_repair import _MAX_MALFORMED_BACKUPS, _MAX_PERSISTENT_REPAIR_ATTEMPTS, _backup_db_file, _existing_malformed_backups, _persistent_repair_attempts_exhausted, _prune_malformed_backups, _record_repair_outcome, _repair_ledger_path, repair_state_db_schema
|
||||
|
||||
|
||||
def _make_unrepairable_db(tmp_path: Path) -> Path:
|
||||
@@ -76,7 +67,7 @@ class TestPersistentAttemptCap:
|
||||
# Budget burned: the next call must refuse WITHOUT running surgery
|
||||
# (and without taking another backup).
|
||||
backups_before = len(_existing_malformed_backups(db))
|
||||
with patch.object(hermes_state, "_repair_state_db_schema_locked") as surgery:
|
||||
with patch.object(hermes_state_repair, "_repair_state_db_schema_locked") as surgery:
|
||||
report = repair_state_db_schema(db)
|
||||
surgery.assert_not_called()
|
||||
assert report["repaired"] is False
|
||||
|
||||
@@ -29,18 +29,8 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
_MAX_MALFORMED_BACKUPS,
|
||||
_MAX_PERSISTENT_REPAIR_ATTEMPTS,
|
||||
_REPAIR_BACKUP_MIN_FREE_BYTES,
|
||||
_backup_content_identity,
|
||||
_backup_db_file,
|
||||
_db_fingerprint,
|
||||
_existing_malformed_backups,
|
||||
_persistent_repair_attempts_exhausted,
|
||||
_record_repair_outcome,
|
||||
_repair_backup_headroom_bytes,
|
||||
)
|
||||
import hermes_state_repair
|
||||
from hermes_state_repair import _MAX_MALFORMED_BACKUPS, _MAX_PERSISTENT_REPAIR_ATTEMPTS, _REPAIR_BACKUP_MIN_FREE_BYTES, _backup_content_identity, _backup_db_file, _db_fingerprint, _existing_malformed_backups, _persistent_repair_attempts_exhausted, _record_repair_outcome, _repair_backup_headroom_bytes
|
||||
|
||||
|
||||
def _damaged_db(tmp_path: Path, size: int = 200_000) -> Path:
|
||||
@@ -255,7 +245,7 @@ def test_repair_aborts_when_backup_refused_for_disk(tmp_path):
|
||||
"Usage", (), {"total": 0, "used": 0, "free": _REPAIR_BACKUP_MIN_FREE_BYTES // 2}
|
||||
)()
|
||||
with patch("shutil.disk_usage", return_value=tight):
|
||||
report = hermes_state.repair_state_db_schema(db)
|
||||
report = hermes_state_repair.repair_state_db_schema(db)
|
||||
assert not report.get("repaired")
|
||||
assert "free" in (report.get("error") or "").lower()
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import repair_state_db_schema
|
||||
import hermes_state_repair
|
||||
from hermes_state_repair import repair_state_db_schema
|
||||
|
||||
PAGE_SIZE = 4096
|
||||
|
||||
@@ -130,7 +131,7 @@ def _leave_hot_wal_row(db_path: str) -> None:
|
||||
def _probe_repair_lock_from_child(db_path: str, result) -> None:
|
||||
"""Attempt the repair lock with a short timeout from another process."""
|
||||
hermes_state._REPAIR_LOCK_TIMEOUT_SECONDS = 0.5
|
||||
with hermes_state._cross_process_repair_lock(Path(db_path)) as holding:
|
||||
with hermes_state_repair._cross_process_repair_lock(Path(db_path)) as holding:
|
||||
result.put(holding)
|
||||
|
||||
|
||||
@@ -196,13 +197,13 @@ def test_strategies_never_receive_the_live_database(corrupt_db, monkeypatch):
|
||||
"""Every strategy mutates its argument in place, so the property that
|
||||
makes them safe is simply that the argument is never the real file."""
|
||||
seen: list[Path] = []
|
||||
real = hermes_state._run_repair_strategies
|
||||
real = hermes_state_repair._run_repair_strategies
|
||||
|
||||
def spy(path, report):
|
||||
seen.append(path)
|
||||
return real(path, report)
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", spy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", spy)
|
||||
repair_state_db_schema(corrupt_db)
|
||||
|
||||
assert seen, "the repair path did not run at all"
|
||||
@@ -259,7 +260,7 @@ def test_successful_repair_is_promoted_over_the_original(tmp_path, monkeypatch):
|
||||
# Force the "already healthy" short-circuit off so the staging path runs,
|
||||
# and have the strategy pass mark a repair after writing a marker row.
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
def fake_strategies(scratch_path, report):
|
||||
@@ -271,7 +272,7 @@ def test_successful_repair_is_promoted_over_the_original(tmp_path, monkeypatch):
|
||||
report["strategy"] = "test_strategy"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies)
|
||||
|
||||
report = repair_state_db_schema(db)
|
||||
assert report["repaired"] is True
|
||||
@@ -303,7 +304,7 @@ def test_committed_writer_after_staging_is_never_lost(
|
||||
_make_repair_test_db(db, journal_mode=journal_mode)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
ready = multiprocessing.get_context("spawn").Event()
|
||||
start = multiprocessing.get_context("spawn").Event()
|
||||
@@ -329,7 +330,7 @@ def test_committed_writer_after_staging_is_never_lost(
|
||||
report["strategy"] = "race_test"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", staged_strategy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", staged_strategy)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
writer.join(20)
|
||||
if writer.is_alive():
|
||||
@@ -361,11 +362,10 @@ def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch):
|
||||
db = tmp_path / "state.db"
|
||||
_make_repair_test_db(db)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
hermes_state,
|
||||
"_repair_scratch_space_error",
|
||||
hermes_state_repair, "_repair_scratch_space_error",
|
||||
lambda _path: "temporary disk pressure",
|
||||
)
|
||||
|
||||
@@ -374,10 +374,10 @@ def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch):
|
||||
assert report["repaired"] is False
|
||||
assert report["error"] == "temporary disk pressure"
|
||||
|
||||
ledger_path = hermes_state._repair_ledger_path(db)
|
||||
ledger_path = hermes_state_repair._repair_ledger_path(db)
|
||||
assert not ledger_path.exists(), "environmental aborts must not consume attempts"
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_repair_scratch_space_error", lambda _path: None)
|
||||
monkeypatch.setattr(hermes_state_repair, "_repair_scratch_space_error", lambda _path: None)
|
||||
|
||||
def successful_strategy(scratch_path, report):
|
||||
with sqlite3.connect(str(scratch_path)) as conn:
|
||||
@@ -387,7 +387,7 @@ def test_environmental_aborts_do_not_burn_repair_ledger(tmp_path, monkeypatch):
|
||||
report["strategy"] = "after_environmental_aborts"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
assert report["repaired"] is True
|
||||
assert report["strategy"] == "after_environmental_aborts"
|
||||
@@ -397,7 +397,7 @@ def test_actual_strategy_failure_still_consumes_one_attempt(tmp_path, monkeypatc
|
||||
db = tmp_path / "state.db"
|
||||
_make_repair_test_db(db)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
def failed_strategy(_scratch_path, report):
|
||||
@@ -405,10 +405,10 @@ def test_actual_strategy_failure_still_consumes_one_attempt(tmp_path, monkeypatc
|
||||
report["strategy"] = None
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
assert report["repaired"] is False
|
||||
ledger = hermes_state._read_repair_ledger(db)
|
||||
ledger = hermes_state_repair._read_repair_ledger(db)
|
||||
assert ledger["failed_attempts"] == 1
|
||||
|
||||
|
||||
@@ -419,7 +419,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
|
||||
db = tmp_path / "state.db"
|
||||
_make_repair_test_db(db)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
def failed_strategy(_scratch_path, report):
|
||||
@@ -427,10 +427,10 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
|
||||
report["strategy"] = None
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy)
|
||||
observed = []
|
||||
lock_released = threading.Event()
|
||||
real_repair_lock = hermes_state._cross_process_repair_lock
|
||||
real_repair_lock = hermes_state_repair._cross_process_repair_lock
|
||||
|
||||
@contextlib.contextmanager
|
||||
def tracking_repair_lock(path):
|
||||
@@ -439,7 +439,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
|
||||
lock_released.set()
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_cross_process_repair_lock", tracking_repair_lock
|
||||
hermes_state_repair, "_cross_process_repair_lock", tracking_repair_lock
|
||||
)
|
||||
|
||||
def record_outcome(_db_path, *, repaired, fingerprint=None):
|
||||
@@ -462,7 +462,7 @@ def test_repair_outcome_is_recorded_while_cross_process_lock_is_held(
|
||||
probe.join(5)
|
||||
assert repaired is False
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_record_repair_outcome", record_outcome)
|
||||
monkeypatch.setattr(hermes_state_repair, "_record_repair_outcome", record_outcome)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
assert report["repaired"] is False
|
||||
@@ -484,9 +484,9 @@ def test_exhaustion_is_rechecked_after_acquiring_repair_lock(tmp_path, monkeypat
|
||||
return len(exhaustion_checks) >= 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_persistent_repair_attempts_exhausted", exhaustion_probe
|
||||
hermes_state_repair, "_persistent_repair_attempts_exhausted", exhaustion_probe
|
||||
)
|
||||
monkeypatch.setattr(hermes_state, "_live_writer_holds_db", lambda _path: False)
|
||||
monkeypatch.setattr(hermes_state_repair, "_live_writer_holds_db", lambda _path: False)
|
||||
surgery_calls = []
|
||||
|
||||
def unexpected_surgery(_db_path, *, backup, report):
|
||||
@@ -494,7 +494,7 @@ def test_exhaustion_is_rechecked_after_acquiring_repair_lock(tmp_path, monkeypat
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_repair_state_db_schema_locked", unexpected_surgery
|
||||
hermes_state_repair, "_repair_state_db_schema_locked", unexpected_surgery
|
||||
)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
@@ -513,7 +513,7 @@ def test_scratch_budget_counts_sidecars_in_vacuum_multiplier(tmp_path, monkeypat
|
||||
db.write_bytes(b"m" * main_bytes)
|
||||
db.with_name(db.name + "-wal").write_bytes(b"w" * wal_bytes)
|
||||
total = 10_000_000_000
|
||||
headroom = hermes_state._repair_backup_headroom_bytes(total)
|
||||
headroom = hermes_state_repair._repair_backup_headroom_bytes(total)
|
||||
# This exactly satisfies the obsolete ``snapshot + 2*main + headroom``
|
||||
# calculation, but is below the corrected ``3*snapshot + headroom``.
|
||||
old_required = main_bytes + wal_bytes + (2 * main_bytes) + headroom
|
||||
@@ -523,7 +523,7 @@ def test_scratch_budget_counts_sidecars_in_vacuum_multiplier(tmp_path, monkeypat
|
||||
lambda _path: SimpleNamespace(total=total, free=old_required),
|
||||
)
|
||||
|
||||
error = hermes_state._repair_scratch_space_error(db)
|
||||
error = hermes_state_repair._repair_scratch_space_error(db)
|
||||
|
||||
assert error is not None
|
||||
assert "VACUUM may need another" in error
|
||||
@@ -536,7 +536,7 @@ def test_environmental_promotion_failures_do_not_burn_ledger(
|
||||
db = tmp_path / "state.db"
|
||||
_make_repair_test_db(db)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
def successful_strategy(_scratch_path, report):
|
||||
@@ -544,8 +544,8 @@ def test_environmental_promotion_failures_do_not_burn_ledger(
|
||||
report["strategy"] = "promotion_environment_test"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy)
|
||||
real_copy = hermes_state._copy_database_snapshot
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy)
|
||||
real_copy = hermes_state_repair._copy_database_snapshot
|
||||
copy_calls = 0
|
||||
|
||||
def fail_promotion_three_times(source, destination, **kwargs):
|
||||
@@ -556,17 +556,17 @@ def test_environmental_promotion_failures_do_not_burn_ledger(
|
||||
return real_copy(source, destination, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_copy_database_snapshot", fail_promotion_three_times
|
||||
hermes_state_repair, "_copy_database_snapshot", fail_promotion_three_times
|
||||
)
|
||||
for _ in range(3):
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
assert report["repaired"] is False
|
||||
assert "could not be promoted" in report["error"]
|
||||
|
||||
ledger = hermes_state._read_repair_ledger(db)
|
||||
ledger = hermes_state_repair._read_repair_ledger(db)
|
||||
assert ledger.get("failed_attempts", 0) == 0
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_copy_database_snapshot", real_copy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_copy_database_snapshot", real_copy)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
assert report["repaired"] is True
|
||||
assert report["strategy"] == "promotion_environment_test"
|
||||
@@ -577,7 +577,7 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch):
|
||||
db = tmp_path / "state.db"
|
||||
_make_repair_test_db(db)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
def successful_strategy(_scratch_path, report):
|
||||
@@ -585,8 +585,8 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch):
|
||||
report["strategy"] = "corrupt-promotion-test"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", successful_strategy)
|
||||
real_copy = hermes_state._copy_database_snapshot
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", successful_strategy)
|
||||
real_copy = hermes_state_repair._copy_database_snapshot
|
||||
calls = 0
|
||||
|
||||
def fail_promotion_with_corruption(source, destination, **kwargs):
|
||||
@@ -597,12 +597,12 @@ def test_corrupt_promotion_failure_consumes_one_attempt(tmp_path, monkeypatch):
|
||||
return real_copy(source, destination, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_copy_database_snapshot", fail_promotion_with_corruption
|
||||
hermes_state_repair, "_copy_database_snapshot", fail_promotion_with_corruption
|
||||
)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
assert report["repaired"] is False
|
||||
assert hermes_state._read_repair_ledger(db)["failed_attempts"] == 1
|
||||
assert hermes_state_repair._read_repair_ledger(db)["failed_attempts"] == 1
|
||||
|
||||
|
||||
def test_snapshot_includes_committed_wal_frames(tmp_path):
|
||||
@@ -617,7 +617,7 @@ def test_snapshot_includes_committed_wal_frames(tmp_path):
|
||||
assert db.with_name(db.name + "-wal").exists()
|
||||
|
||||
scratch = tmp_path / "state.db.repair-scratch"
|
||||
hermes_state._copy_database_snapshot(db, scratch)
|
||||
hermes_state_repair._copy_database_snapshot(db, scratch)
|
||||
with sqlite3.connect(str(scratch)) as check:
|
||||
assert check.execute("SELECT body FROM messages").fetchall() == [
|
||||
("committed-only-in-wal",)
|
||||
@@ -646,7 +646,7 @@ def test_failed_wal_repair_preserves_committed_rows_semantically(
|
||||
pytest.skip("SQLite/filesystem did not retain a hot WAL sidecar")
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
strategy_calls = []
|
||||
@@ -657,7 +657,7 @@ def test_failed_wal_repair_preserves_committed_rows_semantically(
|
||||
report["strategy"] = None
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", failed_strategy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", failed_strategy)
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
assert report["repaired"] is False
|
||||
assert strategy_calls == [True], "the test must exercise strategy failure"
|
||||
@@ -673,12 +673,12 @@ def test_snapshot_deadline_has_a_floor_and_scales_with_source_size(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Large snapshots get more than the lock floor without huge fixtures."""
|
||||
deadline = getattr(hermes_state, "_repair_snapshot_timeout_seconds", None)
|
||||
deadline = getattr(hermes_state_repair, "_repair_snapshot_timeout_seconds", None)
|
||||
assert callable(deadline), "repair snapshots need a size-scaled deadline"
|
||||
# Lower the throughput only for this arithmetic test so a 72 MiB fixture
|
||||
# crosses the floor without allocating a multi-GB file.
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND", 256 * 1024
|
||||
hermes_state_repair, "_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND", 256 * 1024
|
||||
)
|
||||
|
||||
db = tmp_path / "state.db"
|
||||
@@ -711,7 +711,7 @@ def test_transactional_promotion_preserves_a_live_wal_reader(tmp_path):
|
||||
reader.execute("BEGIN")
|
||||
assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)]
|
||||
|
||||
hermes_state._copy_database_snapshot(scratch, live)
|
||||
hermes_state_repair._copy_database_snapshot(scratch, live)
|
||||
|
||||
assert reader.execute("SELECT body FROM messages").fetchall() == [("old",)]
|
||||
with sqlite3.connect(str(live)) as fresh:
|
||||
@@ -742,7 +742,7 @@ def test_interrupted_snapshot_rolls_back_destination(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hermes_state.time, "monotonic", lambda: next(ticks))
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
hermes_state._copy_database_snapshot(source, destination)
|
||||
hermes_state_repair._copy_database_snapshot(source, destination)
|
||||
|
||||
with sqlite3.connect(str(destination)) as conn:
|
||||
assert conn.execute("SELECT value FROM marker").fetchall() == [("original",)]
|
||||
@@ -752,7 +752,7 @@ def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monke
|
||||
db = tmp_path / "state.db"
|
||||
_write_populated_db(db)
|
||||
before = hashlib.sha256(db.read_bytes()).hexdigest()
|
||||
real_copy = hermes_state._copy_database_snapshot
|
||||
real_copy = hermes_state_repair._copy_database_snapshot
|
||||
calls = 0
|
||||
|
||||
def fail_second_copy(source, destination, **kwargs):
|
||||
@@ -768,10 +768,10 @@ def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monke
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
monkeypatch.setattr(hermes_state, "_copy_database_snapshot", fail_second_copy)
|
||||
monkeypatch.setattr(hermes_state, "_run_repair_strategies", fake_strategies)
|
||||
monkeypatch.setattr(hermes_state_repair, "_copy_database_snapshot", fail_second_copy)
|
||||
monkeypatch.setattr(hermes_state_repair, "_run_repair_strategies", fake_strategies)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
@@ -785,12 +785,12 @@ def test_failed_promotion_returns_failure_and_preserves_original(tmp_path, monke
|
||||
def test_scratch_space_guard_accounts_for_snapshot_and_vacuum(tmp_path, monkeypatch):
|
||||
db = tmp_path / "state.db"
|
||||
db.write_bytes(b"x" * 4096)
|
||||
headroom = hermes_state._repair_backup_headroom_bytes(10_000_000_000)
|
||||
headroom = hermes_state_repair._repair_backup_headroom_bytes(10_000_000_000)
|
||||
free = (3 * db.stat().st_size) + headroom - 1
|
||||
usage = SimpleNamespace(total=10_000_000_000, free=free)
|
||||
monkeypatch.setattr(shutil, "disk_usage", lambda _path: usage)
|
||||
|
||||
error = hermes_state._repair_scratch_space_error(db)
|
||||
error = hermes_state_repair._repair_scratch_space_error(db)
|
||||
|
||||
assert error is not None
|
||||
assert "VACUUM may need" in error
|
||||
@@ -808,7 +808,7 @@ def test_stale_scratch_is_removed_before_health_check(tmp_path, monkeypatch):
|
||||
assert not scratch.exists()
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health)
|
||||
monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", fake_health)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
|
||||
@@ -826,7 +826,7 @@ def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch):
|
||||
checked_space = False
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
hermes_state_repair, "_db_opens_cleanly", lambda _path: "forced-unhealthy"
|
||||
)
|
||||
|
||||
def fake_space_check(_path):
|
||||
@@ -836,7 +836,7 @@ def test_stale_scratch_is_removed_before_space_check(tmp_path, monkeypatch):
|
||||
return "forced low space"
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_repair_scratch_space_error", fake_space_check
|
||||
hermes_state_repair, "_repair_scratch_space_error", fake_space_check
|
||||
)
|
||||
|
||||
report = repair_state_db_schema(db, backup=False)
|
||||
@@ -857,9 +857,9 @@ def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch
|
||||
probed = True
|
||||
return "forced-unhealthy"
|
||||
|
||||
monkeypatch.setattr(hermes_state, "_db_opens_cleanly", fake_health)
|
||||
monkeypatch.setattr(hermes_state_repair, "_db_opens_cleanly", fake_health)
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "_unlink_db_triple", lambda _path: "scratch is locked"
|
||||
hermes_state_repair, "_unlink_db_triple", lambda _path: "scratch is locked"
|
||||
)
|
||||
report = {
|
||||
"repaired": False,
|
||||
@@ -868,7 +868,7 @@ def test_stale_scratch_cleanup_failure_aborts_before_probe(tmp_path, monkeypatch
|
||||
"error": None,
|
||||
}
|
||||
|
||||
result = hermes_state._repair_state_db_schema_locked(
|
||||
result = hermes_state_repair._repair_state_db_schema_locked(
|
||||
db, backup=False, report=report
|
||||
)
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ import sys
|
||||
import pytest
|
||||
|
||||
import hermes_state
|
||||
from hermes_state import (
|
||||
apply_database_pragmas,
|
||||
resolve_synchronous_level,
|
||||
)
|
||||
import hermes_state_wal
|
||||
from hermes_state import apply_database_pragmas
|
||||
from hermes_state_wal import resolve_synchronous_level
|
||||
|
||||
|
||||
def _wal_conn(tmp_path):
|
||||
@@ -186,7 +185,7 @@ class TestMacOSFloor:
|
||||
conn = _wal_conn(tmp_path)
|
||||
try:
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
hermes_state._enforce_macos_synchronous_full(conn)
|
||||
hermes_state_wal._enforce_macos_synchronous_full(conn)
|
||||
assert _level(conn) == 2
|
||||
with caplog.at_level("WARNING"):
|
||||
apply_database_pragmas(conn, db_label="state.db")
|
||||
|
||||
@@ -3936,7 +3936,7 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(launch_cwd))
|
||||
monkeypatch.setattr(server, "_profile_home", lambda _profile: profile_home)
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda db_path=None: profile_db)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", lambda db_path=None: profile_db)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: launch_db)
|
||||
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda target: [])
|
||||
@@ -7895,7 +7895,7 @@ def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path):
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", _ProfileDB)
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
server._ensure_session_db_row(
|
||||
@@ -9792,7 +9792,7 @@ def test_config_set_model_recovers_failed_profile_resume_after_build_completes(
|
||||
"hermes_cli.model_selection_guards.combined_selection_warning",
|
||||
lambda *args, **kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", FakeDb)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", FakeDb)
|
||||
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
|
||||
monkeypatch.setattr(server, "_transfer_db_to_agent", barrier_transfer)
|
||||
monkeypatch.setattr(
|
||||
@@ -14620,8 +14620,10 @@ def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch):
|
||||
def _broken_shared(_db_path=None):
|
||||
raise RuntimeError("locking protocol")
|
||||
|
||||
fake_mod.get_shared_session_db = _broken_shared
|
||||
monkeypatch.setitem(sys.modules, "hermes_state", fake_mod)
|
||||
fake_registry = types.ModuleType("hermes_state_registry")
|
||||
fake_registry.acquire = _broken_shared
|
||||
monkeypatch.setitem(sys.modules, "hermes_state_registry", fake_registry)
|
||||
monkeypatch.setattr(server, "_db", None)
|
||||
monkeypatch.setattr(server, "_db_error", None)
|
||||
|
||||
@@ -14643,8 +14645,10 @@ def test_ensure_session_db_row_false_when_store_unavailable(monkeypatch):
|
||||
def _broken_shared(_db_path=None):
|
||||
raise RuntimeError("utf-8 boom")
|
||||
|
||||
fake_mod.get_shared_session_db = _broken_shared
|
||||
monkeypatch.setitem(sys.modules, "hermes_state", fake_mod)
|
||||
fake_registry = types.ModuleType("hermes_state_registry")
|
||||
fake_registry.acquire = _broken_shared
|
||||
monkeypatch.setitem(sys.modules, "hermes_state_registry", fake_registry)
|
||||
monkeypatch.setattr(server, "_db", None)
|
||||
monkeypatch.setattr(server, "_db_error", None)
|
||||
|
||||
@@ -14996,7 +15000,7 @@ def test_session_list_honors_params_profile_opens_profile_db(monkeypatch, tmp_pa
|
||||
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
@@ -15037,7 +15041,7 @@ def test_session_most_recent_honors_params_profile(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB2)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB2)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
@@ -15164,7 +15168,7 @@ def test_session_delete_honors_params_profile_sessions_dir(monkeypatch, tmp_path
|
||||
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
@@ -15231,7 +15235,7 @@ def test_session_title_uses_session_profile_db_not_launch(monkeypatch, tmp_path)
|
||||
"last_active": 1.0,
|
||||
}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
try:
|
||||
set_resp = server.handle_request(
|
||||
{
|
||||
@@ -15288,7 +15292,7 @@ def test_session_history_uses_session_profile_db(monkeypatch, tmp_path):
|
||||
"last_active": 1.0,
|
||||
}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.history", "params": {"session_id": "sid"}}
|
||||
@@ -15375,7 +15379,7 @@ def test_session_status_uses_session_profile_db(monkeypatch, tmp_path):
|
||||
"last_active": 1.0,
|
||||
}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
|
||||
@@ -15417,7 +15421,7 @@ def test_teardown_ends_session_in_profile_db(monkeypatch, tmp_path):
|
||||
seen["closed"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
session = {
|
||||
"session_key": "ml-sess",
|
||||
"profile_home": str(profile_home),
|
||||
@@ -15510,7 +15514,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path):
|
||||
}
|
||||
server._sessions["parent"] = parent
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
|
||||
|
||||
def _fake_make_agent(*a, **k):
|
||||
@@ -15932,7 +15936,7 @@ def test_session_branch_installs_parent_profile_secret_scope(monkeypatch, tmp_pa
|
||||
}
|
||||
server._sessions["parent"] = parent
|
||||
monkeypatch.setattr(server, "_get_db", lambda: ProfileDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
|
||||
|
||||
def _fake_make_agent(*a, **k):
|
||||
@@ -16049,7 +16053,7 @@ def test_session_branch_uses_persisted_display_history_after_compaction(monkeypa
|
||||
}
|
||||
server._sessions["parent"] = parent
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *args, **kwargs: (None, None))
|
||||
monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: FakeAgent())
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda *args, **kwargs: {})
|
||||
@@ -16109,7 +16113,7 @@ def test_pending_title_finalizer_uses_session_profile_db(monkeypatch, tmp_path):
|
||||
seen["closed"] = True
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", ProfileDB)
|
||||
session = {
|
||||
"session_key": "ml-sess",
|
||||
"pending_title": "deferred-title",
|
||||
|
||||
@@ -151,9 +151,9 @@ class TestBrowseShape:
|
||||
return []
|
||||
|
||||
db = _DB()
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", lambda: db)
|
||||
monkeypatch.setattr(
|
||||
"hermes_state.release_or_close",
|
||||
"hermes_state_registry.release_or_close",
|
||||
lambda _: setattr(db, "released", db.released + 1),
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def homes(monkeypatch, tmp_path):
|
||||
homes = {name: tmp_path / name for name in ("a", "b")}
|
||||
for home in homes.values():
|
||||
home.mkdir()
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _DB)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", _DB)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
||||
monkeypatch.setattr(server, "_profile_home", lambda p: homes.get(p) if p else None)
|
||||
monkeypatch.setattr(server, "_profile_configured_cwd", lambda _home: str(tmp_path))
|
||||
|
||||
@@ -178,7 +178,7 @@ def test_lazy_recall_open_is_owned_by_the_agent(monkeypatch):
|
||||
opened.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", _factory)
|
||||
|
||||
agent = _bare_agent(_session_db=None, _persist_disabled=False)
|
||||
got = agent._get_session_db_for_recall()
|
||||
@@ -272,7 +272,7 @@ def build_env(monkeypatch, tmp_path):
|
||||
opened.append(db)
|
||||
return db
|
||||
|
||||
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
|
||||
monkeypatch.setattr("hermes_state_registry.acquire", _factory)
|
||||
for name, value in [
|
||||
("_set_session_context", lambda _key: []),
|
||||
("_clear_session_context", lambda _tokens: None),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user