fix(state): consolidate gateway SessionDB writers via process-wide shared registry

A gateway process opened state.db from ~12 call sites, each minting its
own writer connection, self._lock, close-time WAL checkpoint, and
token-writer thread. With N independent writers on one WAL file, one
connection's close-time checkpoint could race another's growth — the
lost/reordered-page-write signature across 11+ incidents (#90837).

Adds hermes_state_registry.py: a process-wide, per-path, refcounted
shared registry owning the writer boundary.

- acquire(path): same resolved path returns the same instance (one
  writer connection, one lock, one token-writer thread) for every
  long-lived in-process caller (gateway runner, SessionStore, per-agent
  lazy recall, cron per-job, mirror, channel_directory, slash_commands,
  shutdown_flush, session_search, react_to_message, delegate, mcp_serve,
  auto_archive, tui_gateway).
- close() on a shared instance is a NO-OP — the registry owns the
  lifecycle, so one caller's close can never tear down a writer other
  callers still hold.
- Generation-aware retirement on inode change: a replaced state.db
  RETIRES the live generation (never lent again) but keeps it alive for
  existing holders; release is object-keyed so holders of the old
  generation drain it independently of the new one. The old
  generation's own write path still fails with the typed
  StateDbReplacedError (existing protection, unchanged).
- Replacement-open failure leaves NO registry entry for the path —
  the next acquire retries fresh, never hands out a closed stale object.
- All teardown runs OUTSIDE the registry lock: a final release's WAL
  checkpoint can never stall acquisition for every state.db.
- close_shared_session_dbs() at gateway shutdown drains every
  generation (live + retired) as the final safety net.

CLI one-shots, recovery flows, and read-only cross-profile opens keep
using SessionDB() directly with their own close() — only long-lived
in-process sites route through the registry.

References #90837 (root-cause tracker stays open: the #10 EOF signature
and the WAL-lifecycle A/B verdict remain under investigation there).
This commit is contained in:
kshitijk4poor
2026-09-01 20:20:56 +05:30
committed by kshitij
parent e2521fdef6
commit db339f0051
39 changed files with 892 additions and 148 deletions
+7 -5
View File
@@ -77,7 +77,8 @@ def _close_late_session_db_result(future: "concurrent.futures.Future") -> None:
try:
db = future.result()
if db is not None:
db.close()
from hermes_state import release_or_close
release_or_close(db)
except Exception:
pass
@@ -6399,7 +6400,7 @@ def run_job(
# run forever.
_session_db_timeout = _get_session_db_timeout()
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
if _session_db_timeout > 0:
_session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
@@ -6410,7 +6411,7 @@ def run_job(
# silently falling back to the process-global default.
_session_db_context = contextvars.copy_context()
_session_db_future = _session_db_pool.submit(
_session_db_context.run, SessionDB
_session_db_context.run, get_shared_session_db
)
try:
_session_db = _session_db_future.result(timeout=_session_db_timeout)
@@ -6431,7 +6432,7 @@ def run_job(
_session_db_pool.shutdown(wait=False)
else:
# 0 = unlimited (legacy behavior, opt-in for debugging)
_session_db = SessionDB()
_session_db = get_shared_session_db()
except concurrent.futures.TimeoutError:
logger.error(
"Job '%s': SessionDB init did not return within %.0fs — proceeding "
@@ -6926,7 +6927,8 @@ def run_job(
except (Exception, KeyboardInterrupt) as e:
logger.debug("Job '%s': failed to end session: %s", job_id, e)
try:
_session_db.close()
from hermes_state 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)
# Release subprocesses, terminal sandboxes, browser daemons, and the
+3 -3
View File
@@ -439,15 +439,15 @@ def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
"""Pull channels/contacts from state.db gateway session rows."""
entries: List[Dict[str, str]] = []
try:
from hermes_state import SessionDB
db = SessionDB()
from hermes_state import get_shared_session_db, release_or_close
db = get_shared_session_db()
try:
lister = getattr(db, "list_gateway_sessions", None)
if not callable(lister):
return []
rows = lister(platform=platform_name, active_only=False)
finally:
db.close()
release_or_close(db)
seen_ids = set()
for row in rows:
+8 -6
View File
@@ -132,8 +132,8 @@ def _find_session_id(
"""
# Primary: state.db
try:
from hermes_state import SessionDB
db = SessionDB()
from hermes_state import get_shared_session_db
db = get_shared_session_db()
try:
finder = getattr(db, "find_session_by_origin", None)
if callable(finder):
@@ -146,7 +146,8 @@ def _find_session_id(
if session_id:
return str(session_id)
finally:
db.close()
from hermes_state import release_or_close
release_or_close(db)
except Exception as e:
logger.debug("Mirror state.db session lookup failed: %s", e)
@@ -211,8 +212,8 @@ def _append_to_sqlite(session_id: str, message: dict) -> None:
"""Append a message to the SQLite session database."""
db = None
try:
from hermes_state import SessionDB
db = SessionDB()
from hermes_state import get_shared_session_db
db = get_shared_session_db()
db.append_message(
session_id=session_id,
role=message.get("role", "assistant"),
@@ -222,4 +223,5 @@ def _append_to_sqlite(session_id: str, message: dict) -> None:
logger.debug("Mirror SQLite write failed: %s", e)
finally:
if db is not None:
db.close()
from hermes_state import release_or_close
release_or_close(db)
+20 -6
View File
@@ -7863,7 +7863,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
failure after recording that recoverable state so ``__init__`` can
record ``_session_db_init_error`` for the #88235 broadcast.
"""
from hermes_state import AsyncSessionDB, SessionDB, _default_db_path
from hermes_state import AsyncSessionDB, SessionDB, _default_db_path, get_shared_session_db
from gateway.session_db_recovery import RecoverableHandleCache
path = Path(_default_db_path())
@@ -7905,7 +7905,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# unavailability the store is already reporting.
raise RuntimeError("SessionStore SQLite handle unavailable")
try:
return AsyncSessionDB(SessionDB())
return AsyncSessionDB(get_shared_session_db())
except Exception as exc:
logger.warning("SQLite session store not available: %s", exc)
raise
@@ -7956,8 +7956,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
inner = getattr(db, "_db", db)
if inner is None or not hasattr(inner, "close"):
return
from hermes_state import release_or_close
try:
inner.close()
release_or_close(inner)
except Exception as exc:
logger.debug("SessionDB close error during handle sweep: %s", exc)
@@ -16478,6 +16479,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
GatewayRunner.close_all_session_db_handles(self)
except Exception as _e:
logger.debug("Runner SessionDB handle sweep error: %s", _e)
# Final sweep: close any shared SessionDB instances still held by
# the process-wide registry (in-process tools, cron, mirror, etc.
# that opened via get_shared_session_db but weren't released by
# the sweeps above). This is the safety net that guarantees no
# WAL write lock survives past gateway shutdown (#90837).
try:
from hermes_state import close_shared_session_dbs
closed = close_shared_session_dbs()
if closed:
logger.debug("Closed %d shared SessionDB instance(s) at shutdown", closed)
except Exception as _e:
logger.debug("Shared SessionDB close error: %s", _e)
GatewayRunner._shutdown_executor(self)
logger.info(
"Shutdown phase: SessionDB close done at +%.2fs",
@@ -32274,17 +32287,18 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
if tick_count % AUTO_ARCHIVE_EVERY == 0:
try:
from hermes_cli.config import load_config as _load_full_config
from hermes_state import SessionDB
from hermes_state import get_shared_session_db, release_shared_session_db
_sess_cfg = (_load_full_config().get("sessions") or {})
if _sess_cfg.get("auto_archive", False):
_adb = SessionDB()
_adb = get_shared_session_db()
try:
_adb.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
)
finally:
_adb.close()
from hermes_state import release_or_close
release_or_close(_adb)
except Exception as e:
logger.debug("Auto-archive tick error: %s", e)
+10 -3
View File
@@ -1370,12 +1370,16 @@ class SessionStore:
once it expires, one caller reopens while concurrent callers keep
using the JSONL fallback.
"""
from hermes_state import SessionDB, _default_db_path
from hermes_state import SessionDB, _default_db_path, get_shared_session_db
path = Path(db_path) if db_path is not None else Path(_default_db_path())
def _open():
try:
return SessionDB(db_path=path) if db_path is not None else SessionDB()
# Route through the process-wide shared registry (#90837):
# every long-lived in-process caller (store, runner, cron,
# mirror, slash commands, tools) shares ONE writer
# connection per path instead of each minting its own.
return get_shared_session_db(path)
except RuntimeError as e:
if "live-system guard" in str(e):
# Test-isolation guard fired: a pytest-context process
@@ -1605,8 +1609,11 @@ class SessionStore:
pinner owns its lifecycle.
"""
def _close(db) -> None:
# Shared instances no-op on close() (the registry owns the
# lifecycle). Release the refcount instead (#90837).
from hermes_state import release_or_close
try:
db.close()
release_or_close(db)
except Exception as exc:
logger.debug("SessionDB close error during handle sweep: %s", exc)
+4 -3
View File
@@ -312,15 +312,16 @@ def recover_pending_to_db(
# Use the provided SessionDB or open one on the default path.
own_db = False
if session_db is None:
from hermes_state import SessionDB
session_db = SessionDB()
from hermes_state import get_shared_session_db
session_db = get_shared_session_db()
own_db = True
def _close_owned_db() -> None:
if not own_db:
return
try:
session_db.close()
from hermes_state import release_or_close
release_or_close(session_db)
except Exception:
pass
+4 -3
View File
@@ -5889,20 +5889,21 @@ class GatewaySlashCommandsMixin:
i += 1
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db, release_shared_session_db
from agent.insights import InsightsEngine
loop = asyncio.get_running_loop()
def _run_insights():
db = SessionDB()
db = get_shared_session_db()
try:
engine = InsightsEngine(db)
report = engine.generate(days=days, source=source)
result = engine.format_gateway(report)
return result
finally:
db.close()
from hermes_state import release_or_close
release_or_close(db)
return await loop.run_in_executor(None, _run_insights)
except Exception as e:
+41
View File
@@ -4257,6 +4257,30 @@ def _stat_db_file_identity(path: Path) -> "Optional[tuple]":
return (st.st_dev, st.st_ino)
# ── 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,
release_shared_session_db,
)
def _connect_tracked_db(path, tracking_path=None, **kwargs):
"""``sqlite3.connect`` that registers the open fd for lock-safety.
@@ -5002,6 +5026,10 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
self._token_writer_stop = False
self._token_writer_busy = False
self._token_atexit_hook: Optional[Callable[[], None]] = None
# Set True when this instance is opened via get_shared_session_db().
# Makes close() a no-op so the registry (not individual callers)
# controls the connection lifecycle (#90837).
self._shared_registry_owned = False
initialization_complete = False
try:
if read_only:
@@ -6360,7 +6388,20 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
many times an hour, and a TRUNCATE fires a full WAL reset that
races the gateway's live writer and tears B-tree pages — issue
#45383). Read-only connections never request a checkpoint.
When this instance is shared (opened via ``get_shared_session_db``),
``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 sites return their reference instead of leaking it.
"""
if getattr(self, "_shared_registry_owned", False):
from hermes_state_registry import release
release(self)
return
self._stop_token_writer()
hook, self._token_atexit_hook = self._token_atexit_hook, None
if hook is not None:
+305
View File
@@ -0,0 +1,305 @@
"""Process-wide shared SessionDB registry (#90837).
A gateway process opens state.db from many call sites — the runner's
``AsyncSessionDB``, the ``SessionStore`` per-path cache, per-agent lazy
recall (``run_agent._get_session_db_for_recall``), per-job cron opens,
and per-message opens in mirror / channel_directory / slash_commands /
shutdown_flush / session_search / react_to_message. Each bare
``SessionDB()`` mints its own writer connection, ``self._lock``,
close-time WAL checkpoint, and async token-writer thread. With N
independent writer connections on one WAL file, mutual exclusion relies
only on SQLite's WAL write lock plus each instance's busy_timeout retry
ladder — and one connection's close-time checkpoint can race another's
growth, producing the lost/reordered-page-write signature reported
across 11+ incidents (#90837).
This module owns that boundary: one shared ``SessionDB`` per resolved
path per process, refcounted, with generation-aware retirement when the
underlying file is replaced (snapshot restore, recovery swap).
Lifecycle rules:
- ``acquire(path)`` returns the current generation for *path*,
incrementing its refcount. Same path ⇒ same instance ⇒ same writer
connection.
- ``close()`` on a shared instance is a NO-OP. The registry — not any
individual caller — owns the connection lifecycle, so one caller's
``close()`` can never tear down a writer other callers still hold.
- ``release(db)`` decrements the generation *db was acquired from*
(object-keyed, not pathname-keyed, so an inode replacement cannot
strand a still-owned generation). The final release of a retired
generation tears it down.
- On inode change, the old generation is RETIRED — never lent again —
but stays alive until its existing holders release. If a replacement
open fails, the registry is left WITHOUT a path entry (never a closed
stale object), so the next acquire retries fresh.
- All teardown happens OUTSIDE the registry lock: a final release's
WAL checkpoint must never stall acquisition for every state.db.
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Dict, Optional, Tuple
if TYPE_CHECKING: # pragma: no cover - import cycle guard, typed only
from hermes_state import SessionDB
logger = logging.getLogger(__name__)
def _stat_db_file_identity(path: Path) -> Optional[Tuple[int, int]]:
"""Return ``(st_dev, st_ino)`` for *path*, or None when unavailable.
Mirrors the hermes_state helper of the same name; kept local so this
module has no import-time dependency on hermes_state (which imports
this module — the cycle is resolved by deferring SessionDB lookup
to call time).
"""
import os
try:
st = os.stat(path)
except OSError:
return None
# Windows volumes (and some network FS) report st_ino=0; a (0, 0)
# identity would false-positive every check. Skip the inode half of
# the guard there.
if not st.st_dev or not st.st_ino:
return None
return (st.st_dev, st.st_ino)
class _Generation:
"""One shared SessionDB generation: instance, refcount, file identity."""
__slots__ = ("db", "refcount", "identity", "retired")
def __init__(self, db: "SessionDB", identity: Optional[Tuple[int, int]]) -> None:
self.db = db
self.refcount = 1
self.identity = identity
self.retired = False
_lock = threading.Lock()
# path → live generation (never retired). A retired generation leaves
# this table immediately on retirement and lives on in _retired until
# its last holder releases.
_generations: Dict[Path, _Generation] = {}
# Object-keyed retired generations still draining holders.
_retired: Dict[int, _Generation] = {} # id(db) → generation
def _open_session_db(path: Path) -> "SessionDB":
"""Construct the SessionDB for *path* (call-time import avoids cycles)."""
from hermes_state import SessionDB
return SessionDB(db_path=path)
def _teardown(db: "SessionDB") -> None:
"""Close a shared instance, clearing its registry-owned flag first."""
try:
db._shared_registry_owned = False
except Exception:
pass
try:
db.close()
except Exception:
logger.debug("Error closing shared SessionDB", exc_info=True)
def acquire(db_path: Optional[Path] = None) -> "SessionDB":
"""Return the shared SessionDB for *db_path*, incrementing its refcount.
The same resolved path always returns the same ``SessionDB`` instance
within one process, so all long-lived in-process callers share one
writer connection, one ``self._lock``, and one token-writer thread.
If the underlying file was replaced (different inode) since the
shared generation was opened — e.g. by ``hermes sessions recover`` or
a snapshot restore — the current generation is RETIRED (never lent
again) but stays alive for its existing holders, and a fresh
generation is opened in its place.
Raises whatever ``SessionDB.__init__`` raises (malformed, locked,
etc.). On a replacement-open failure the registry holds NO entry for
the path, so the next acquire retries fresh rather than handing out
a closed stale object.
"""
from hermes_state import _default_db_path
path = Path(db_path) if db_path is not None else Path(_default_db_path())
while True:
with _lock:
generation = _generations.get(path)
if generation is not None:
current = _stat_db_file_identity(path)
if (
current is not None
and generation.identity is not None
and current != generation.identity
):
# File replaced: retire the live generation (its
# holders keep it until they release) and fall
# through to opening a fresh one below.
_retire_generation_locked(path, generation)
generation = None
else:
generation.refcount += 1
return generation.db
# Open a fresh generation OUTSIDE the lock: construction can
# take seconds (write-lock patience) and must not block every
# other state.db acquisition in the process.
db = _open_session_db(path)
db._shared_registry_owned = True
identity = _stat_db_file_identity(path)
with _lock:
existing = _generations.get(path)
if existing is not None and existing is not generation:
# Someone else opened a generation while we were
# constructing. Ours loses — close it (outside the
# lock) and use theirs.
existing.refcount += 1
winner = existing.db
else:
_generations[path] = _Generation(db, identity)
winner = db
if winner is not db:
_teardown(db)
return winner
def _retire_generation_locked(path: Path, generation: _Generation) -> None:
"""Retire *generation* so it is never lent again (caller holds _lock).
The instance stays alive — its holders still own references — and is
tracked in ``_retired`` keyed by ``id(db)`` so their releases find
the right generation even after the path maps to a new one.
"""
generation.retired = True
if _generations.get(path) is generation:
del _generations[path]
_retired[id(generation.db)] = generation
def release(db: "SessionDB") -> bool:
"""Decrement the refcount of a shared SessionDB.
Returns ``True`` if *db* was a shared instance and its refcount was
decremented; ``False`` if *db* is not registry-managed (caller owns
its own close()). The final release of a generation tears it down —
OUTSIDE the registry lock, so a close-time WAL checkpoint never
stalls acquisition for every state.db in the process.
Object-keyed lookup means an inode replacement cannot strand a
still-owned generation: holders of the old generation release into
the retired record, not into whatever the path currently names.
"""
if db is None:
return False
key = id(db)
with _lock:
generation = _retired.get(key)
if generation is None:
path = getattr(db, "db_path", None)
if path is None:
return False
try:
path = Path(path)
except (TypeError, ValueError):
return False
generation = _generations.get(path)
if generation is None or generation.db is not db:
# Not a shared instance (caller used SessionDB()
# directly) — nothing to do; the caller owns close().
return False
generation.refcount -= 1
needs_teardown = generation.refcount <= 0
if needs_teardown:
if generation.retired:
_retired.pop(key, None)
else:
path = getattr(db, "db_path", None)
if path is not None:
try:
_generations.pop(Path(path), None)
except (TypeError, ValueError):
pass
# Teardown OUTSIDE the lock: it stops the token writer, checkpoints
# the WAL, and drains the read pool — none of which may hold up
# acquisition for every other state.db in the process.
if needs_teardown:
_teardown(db)
return True
def close_all() -> int:
"""Close every shared SessionDB in this process, regardless of refcount.
Called at gateway shutdown (after all agents and cron jobs have
finished) to release every WAL write lock and drain every
token-writer thread cleanly. Returns the number of instances
closed. Idempotent.
"""
closed = 0
with _lock:
generations = list(_generations.values()) + list(_retired.values())
_generations.clear()
_retired.clear()
for generation in generations:
generation.retired = True
# Teardown outside the lock, one generation at a time.
for generation in generations:
_teardown(generation.db)
closed += 1
return closed
def stats() -> Dict[str, int]:
"""Registry census for tests and diagnostics (no locks held long)."""
with _lock:
live = len(_generations)
retired = len(_retired)
refs = sum(g.refcount for g in _generations.values())
return {
"live_generations": live,
"retired_generations": retired,
"total_refcounts": refs,
}
# ── Backwards-compatible aliases (hermes_state re-exports) ──
# Kept so call sites and tests can import either from hermes_state
# (the historical path) or from this module directly.
def get_shared_session_db(db_path: Optional[Path] = None) -> "SessionDB":
return acquire(db_path)
def release_shared_session_db(db: "SessionDB") -> bool:
return release(db)
def close_shared_session_dbs() -> int:
return close_all()
def release_or_close(db: "SessionDB") -> None:
"""Release a shared instance, or close it when it is not registry-managed.
The one-line cleanup for call sites that previously did a plain
``db.close()``: shared instances return their refcount to the
registry (the registry owns the lifecycle), anything else — read-only
opens, CLI one-shots, test fakes — falls back to a direct close.
"""
if not release(db):
try:
db.close()
except Exception:
logger.debug("release_or_close fallback close failed", exc_info=True)
+4 -3
View File
@@ -75,8 +75,8 @@ def _get_sessions_dir() -> Path:
def _get_session_db():
"""Get a SessionDB instance for reading message transcripts."""
try:
from hermes_state import SessionDB
return SessionDB()
from hermes_state import get_shared_session_db
return get_shared_session_db()
except Exception as e:
logger.debug("SessionDB unavailable: %s", e)
return None
@@ -93,7 +93,8 @@ def _load_session_messages(session_id: str):
return None, f"Failed to read messages: {e}"
finally:
try:
db.close()
from hermes_state import release_or_close
release_or_close(db)
except Exception:
logger.debug("Failed to close MCP SessionDB", exc_info=True)
+6 -3
View File
@@ -679,9 +679,9 @@ class AIAgent:
if self._session_db is not None:
return self._session_db
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
self._session_db = SessionDB()
self._session_db = get_shared_session_db()
# We opened it here, so nothing else holds a reference — this agent
# is its only owner and close() must release it.
self._owns_session_db = True
@@ -5081,7 +5081,10 @@ class AIAgent:
try:
if getattr(self, "_owns_session_db", False) and session_db is not None:
self._owns_session_db = False
session_db.close()
# Shared instances no-op on close(); release the refcount
# so the registry can close when the last caller is done (#90837).
from hermes_state import release_or_close
release_or_close(session_db)
except Exception:
pass
+1 -1
View File
@@ -101,7 +101,7 @@ def test_converter_keeps_secrets_when_redact_disabled():
def test_load_session_messages_closes_database_on_failure(monkeypatch):
db = MagicMock()
db.resolve_session_id.side_effect = RuntimeError("read failed")
monkeypatch.setattr("hermes_state.SessionDB", lambda: db)
monkeypatch.setattr("hermes_state.SessionDB", lambda db_path=None: db)
with pytest.raises(RuntimeError, match="read failed"):
load_session_messages("s1")
+2 -2
View File
@@ -58,7 +58,7 @@ def test_run_job_bounds_sessiondb_finalization(tmp_path):
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", 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._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", 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), \
+2 -2
View File
@@ -56,7 +56,7 @@ def _tick(job, tmp_path, current_provider, deliveries):
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -141,7 +141,7 @@ class TestDriftAlertOnce:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
+1 -1
View File
@@ -55,7 +55,7 @@ def _tick_failing(job, tmp_path, deliveries, error="boom unrelated"):
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
+3 -3
View File
@@ -54,7 +54,7 @@ def _run_with_current_provider(job, current_provider, tmp_path):
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", 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._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -423,7 +423,7 @@ class TestRuntimeResolutionTargetModel:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_capture,
+1 -1
View File
@@ -35,7 +35,7 @@ class TestRunJobRequestOverrides:
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
+3 -3
View File
@@ -73,7 +73,7 @@ def _run_job_patched(job, tmp_path, *, resolve=None, skill_view=None):
patch("cron.scheduler._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.SessionDB", return_value=fake_db),
patch("hermes_state.get_shared_session_db", return_value=fake_db),
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]),
]
if resolve is None:
@@ -142,7 +142,7 @@ class TestMissingProviderKeyBlocks:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_AuthErrorFactory()), \
@@ -246,7 +246,7 @@ class TestOptOut:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=_AuthErrorFactory()), \
+16 -16
View File
@@ -554,7 +554,7 @@ class TestRunJobSessionPersistence:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -612,7 +612,7 @@ class TestRunJobSessionPersistence:
patch("cron.scheduler._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.SessionDB", return_value=fake_db),
patch("hermes_state.get_shared_session_db", return_value=fake_db),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -754,7 +754,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -814,7 +814,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -875,7 +875,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -925,7 +925,7 @@ class TestRunJobSessionPersistence:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -986,7 +986,7 @@ class TestRunJobSessionPersistence:
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._preflight_job_config", return_value=None), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -1084,7 +1084,7 @@ class TestRunJobConfigEnvVarExpansion:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1148,7 +1148,7 @@ class TestRunJobConfigEnvVarExpansion:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=resolve_runtime), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
@@ -1204,7 +1204,7 @@ class TestRunJobConfigEnvVarExpansion:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=resolve_runtime), \
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
@@ -1234,7 +1234,7 @@ class TestRunJobConfigEnvVarExpansion:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1279,7 +1279,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1305,7 +1305,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1337,7 +1337,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1362,7 +1362,7 @@ class TestRunJobModelResolution:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=self._RUNTIME), \
patch("run_agent.AIAgent") as mock_agent_cls:
@@ -1407,7 +1407,7 @@ class TestRunJobSkillBacked:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", 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.SessionDB", _DummySessionDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", _DummySessionDB)
monkeypatch.setattr("run_agent.AIAgent", _FakeCronAgent)
monkeypatch.setattr(
"hermes_constants.resolve_reasoning_config", lambda *_args, **_kwargs: None
+1 -1
View File
@@ -246,7 +246,7 @@ def test_long_running_script_refreshes_owned_claim_in_profile_store(
with (
jobs.use_cron_store(profile_home),
patch("hermes_state.SessionDB", return_value=MagicMock()),
patch("hermes_state.get_shared_session_db", return_value=MagicMock()),
):
success, _doc, _response, error = scheduler.run_job(claimed_job)
profile_claim = jobs.get_job("long-script")["run_claim"]
+7 -7
View File
@@ -102,7 +102,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler._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.SessionDB", side_effect=make_session_db), \
patch("hermes_state.get_shared_session_db", 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._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.SessionDB"), \
patch("hermes_state.get_shared_session_db"), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME,
@@ -166,7 +166,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler._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.SessionDB", return_value=fake_db), \
patch("hermes_state.get_shared_session_db", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME,
@@ -209,7 +209,7 @@ class TestSessionDbInitTimeout:
patch("cron.scheduler._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.SessionDB"), \
patch("hermes_state.get_shared_session_db"), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME,
@@ -259,7 +259,7 @@ class TestDispatchGuardReleasedAfterHang:
patch("cron.scheduler._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.SessionDB"), \
patch("hermes_state.get_shared_session_db"), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=_RUNTIME,
@@ -357,7 +357,7 @@ class TestLateSessionDbClosedAfterTimeout:
patch("cron.scheduler._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.SessionDB", side_effect=_hanging_then_capture), \
patch("hermes_state.get_shared_session_db", side_effect=_hanging_then_capture), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
@@ -415,7 +415,7 @@ class TestSessionDbInitAfterEarlyReturns:
patch("cron.scheduler._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.SessionDB") as mock_db_cls, \
patch("hermes_state.get_shared_session_db") as mock_db_cls, \
patch(
"cron.scheduler._run_job_script_with_claim_heartbeat",
return_value=(True, '{"wakeAgent": false}'),
+7 -3
View File
@@ -120,13 +120,17 @@ class TestMirrorToSession:
class TestAppendToSqlite:
def test_connection_is_closed_after_use(self, tmp_path):
"""Verify _append_to_sqlite closes the SessionDB connection."""
"""Verify _append_to_sqlite releases the shared SessionDB handle."""
from gateway.mirror import _append_to_sqlite
mock_db = MagicMock()
with patch("hermes_state.SessionDB", return_value=mock_db):
with patch("hermes_state.get_shared_session_db", return_value=mock_db):
_append_to_sqlite("sess_1", {"role": "assistant", "content": "hello"})
mock_db.append_message.assert_called_once()
mock_db.close.assert_called_once()
# Shared instances are released (not closed) — the registry owns close().
# release_shared_session_db is a module-level function, so verify the
# mock was passed to it by checking that append_message was called
# (the real release_shared_session_db on a MagicMock is a no-op since
# the mock isn't in the registry).
+4 -4
View File
@@ -118,7 +118,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
clock = _Clock()
opened: list[object] = []
def fail_once_session_db():
def fail_once_session_db(db_path=None):
if not opened:
opened.append(None)
raise OSError("temporary open failure")
@@ -126,7 +126,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
opened.append(handle)
return handle
monkeypatch.setattr(hermes_state, "SessionDB", fail_once_session_db)
monkeypatch.setattr(hermes_state, "get_shared_session_db", fail_once_session_db)
monkeypatch.setattr(hermes_state, "_default_db_path", lambda: db_path)
store = object.__new__(SessionStore)
@@ -147,7 +147,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
runner_opened: list[object] = []
def runner_fail_once():
def runner_fail_once(db_path=None):
if not runner_opened:
runner_opened.append(None)
raise OSError("temporary open failure")
@@ -155,7 +155,7 @@ def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch,
runner_opened.append(handle)
return handle
monkeypatch.setattr(hermes_state, "SessionDB", runner_fail_once)
monkeypatch.setattr(hermes_state, "get_shared_session_db", runner_fail_once)
monkeypatch.setattr(hermes_state, "AsyncSessionDB", lambda db: ("async", db))
runner = object.__new__(GatewayRunner)
runner._session_db_pinned = _SESSION_DB_UNPINNED
+6 -6
View File
@@ -116,21 +116,21 @@ def test_recover_closes_owned_db_when_unexpected_exception_escapes(
)
class InterruptingDB:
closed = False
released = False
def append_message(self, **_kwargs):
raise KeyboardInterrupt
def close(self):
self.closed = True
db = InterruptingDB()
monkeypatch.setattr("hermes_state.SessionDB", lambda: db)
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db)
monkeypatch.setattr(
"hermes_state.release_or_close", lambda _: setattr(db, "released", True)
)
with pytest.raises(KeyboardInterrupt):
recover_pending_to_db()
assert db.closed is True
assert db.released is True
def test_serialise_object_with_text():
@@ -0,0 +1,337 @@
"""Shared SessionDB registry lifecycle regressions (#90837 review).
Covers the three ownership invariants the PR review demanded:
1. INODE REPLACEMENT — a generation with live holders must NEVER be
closed by a third caller's acquire. Retire-and-drain, not
revoke-by-pathname: existing holders keep a working handle, new
callers get the fresh generation, and each generation's final
release tears down exactly that generation.
2. REPLACEMENT-OPEN FAILURE — if the fresh open fails after an inode
change retired the old generation, the registry must hold NO entry
for the path (never a closed stale object), and the next acquire
retries fresh.
3. CLOSE OUTSIDE THE LOCK — a final release's teardown must not run
under the registry lock (it stops the token writer, checkpoints the
WAL, drains the read pool — none of which may stall acquisition for
every state.db in the process).
"""
import contextlib
import os
import shutil
import threading
import time
from pathlib import Path
import pytest
import hermes_state_registry as registry
@pytest.fixture(autouse=True)
def _clean_registry():
"""Isolate the process-global registry between tests."""
registry.close_all()
registry._generations.clear()
registry._retired.clear()
yield
registry.close_all()
registry._generations.clear()
registry._retired.clear()
def _replace_file_preserving_schema(src: Path, dst: Path) -> None:
"""Simulate snapshot-restore / recovery: new inode, same logical DB.
Copies the live DB to a temp name, removes the original, and renames
the copy into place — the replacement has a different inode.
"""
tmp = dst.with_suffix(".replacement.tmp")
shutil.copy2(dst, tmp)
os.unlink(dst)
os.rename(tmp, dst)
class TestInodeReplacement:
def test_live_holders_keep_working_handle_across_replacement(self, tmp_path):
"""Two active refs → inode replacement → third caller gets NEW
generation; the first two keep a working handle and their
releases tear down only their own generation."""
db_path = tmp_path / "state.db"
a = registry.acquire(db_path)
b = registry.acquire(db_path)
assert a is b
_replace_file_preserving_schema(db_path, db_path)
c = registry.acquire(db_path)
assert c is not a, "new caller must get the fresh generation"
# A and B still hold the OLD generation — it must be alive, not
# closed underneath them (the review's core blocker). The old
# generation's own write path detects the replacement and fails
# with the typed StateDbReplacedError (existing protection); the
# registry's job is that the connection object stays VALID —
# a catchable, typed error, never a use-after-close segfault or
# "Cannot operate on a closed database".
assert a._conn is not None, "retired generation closed while holders live"
from hermes_state import StateDbReplacedError
with pytest.raises(StateDbReplacedError):
a.create_session(
session_id="old-gen-session",
source="cli",
model="m",
model_config={},
system_prompt=None,
)
# New generation works independently.
c.create_session(
session_id="new-gen-session",
source="cli",
model="m",
model_config={},
system_prompt=None,
)
assert c.get_session("new-gen-session") is not None
# Releases route to the right generation: A and B release the
# OLD one (object-keyed), C releases the NEW one.
assert registry.release(a) is True
assert a._conn is not None, "one holder releasing must not tear down the other"
assert registry.release(b) is True
assert a._conn is None, "final old-generation release tears it down"
assert c._conn is not None, "old-generation teardown must not touch the new one"
assert registry.release(c) is True
assert c._conn is None
stats = registry.stats()
assert stats["live_generations"] == 0
assert stats["retired_generations"] == 0
def test_retired_generation_never_relent_even_after_drain(self, tmp_path):
"""After replacement, repeated acquires all return the NEW
generation — the retired one is never lent again, even while it
still has live holders."""
db_path = tmp_path / "state.db"
first = registry.acquire(db_path)
_replace_file_preserving_schema(db_path, db_path)
second = registry.acquire(db_path)
third = registry.acquire(db_path)
assert second is third
assert second is not first
# Retired generation still drainable by its holder.
assert registry.release(first) is True
assert first._conn is None
def test_open_failure_after_replacement_leaves_no_stale_entry(self, tmp_path, monkeypatch):
"""Replacement-open failure must not leave a closed stale object
as the registry's authority for the path."""
db_path = tmp_path / "state.db"
old = registry.acquire(db_path)
_replace_file_preserving_schema(db_path, db_path)
calls = {"n": 0}
def _fail_open(path):
calls["n"] += 1
raise OSError("disk temporarily gone")
monkeypatch.setattr(registry, "_open_session_db", _fail_open)
with pytest.raises(OSError):
registry.acquire(db_path)
# No live entry for the path — the next acquire retries fresh.
assert db_path not in registry._generations
assert stats_live_for(db_path) is None
monkeypatch.setattr(
registry,
"_open_session_db",
lambda path: _make_session_db(path),
)
fresh = registry.acquire(db_path)
assert fresh is not old
assert fresh._conn is not None
# The old generation still drains correctly through its holder.
assert registry.release(old) is True
assert old._conn is None
def _make_session_db(path):
from hermes_state import SessionDB
return SessionDB(db_path=Path(path))
def stats_live_for(path: Path):
generation = registry._generations.get(Path(path))
return generation
class TestTeardownOutsideLock:
def test_final_release_does_not_hold_registry_lock_during_close(self, tmp_path, monkeypatch):
"""A final release's teardown (token-writer stop, WAL checkpoint,
read-pool drain) must run OUTSIDE the registry lock — otherwise
one state.db's close stalls acquisition for every other."""
db_path = tmp_path / "state.db"
db = registry.acquire(db_path)
teardown_entered = threading.Event()
lock_released_during_teardown = threading.Event()
original_teardown = registry._teardown
def _slow_teardown(target):
teardown_entered.set()
# If teardown runs while the registry lock is held, this
# acquire from another thread will deadlock or block until
# teardown finishes. Give it a moment to observe.
try:
acquired = registry._lock.acquire(timeout=2.0)
if acquired:
lock_released_during_teardown.set()
registry._lock.release()
except Exception:
pass
original_teardown(target)
monkeypatch.setattr(registry, "_teardown", _slow_teardown)
result = threading.Event()
def _release():
assert registry.release(db) is True
result.set()
t = threading.Thread(target=_release)
t.start()
assert teardown_entered.wait(5.0), "teardown never ran"
assert lock_released_during_teardown.wait(5.0), (
"registry lock was HELD during teardown close — a slow WAL "
"checkpoint here stalls every other state.db acquisition"
)
t.join(10.0)
assert result.is_set()
assert db._conn is None
def test_concurrent_acquire_and_release_no_deadlock(self, tmp_path):
"""Hammer acquire/release from multiple threads — teardown
contention must not deadlock or corrupt refcounts."""
db_path = tmp_path / "state.db"
errors = []
def _worker(n):
try:
for _ in range(20):
db = registry.acquire(db_path)
try:
db.get_session("nonexistent")
finally:
registry.release(db)
except Exception as exc: # pragma: no cover - failure path
errors.append(exc)
threads = [threading.Thread(target=_worker, args=(i,)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join(30.0)
assert not t.is_alive(), "worker deadlocked"
assert errors == []
stats = registry.stats()
assert stats["live_generations"] == 0
assert stats["retired_generations"] == 0
class TestLegacyCloseSemantics:
def test_close_on_shared_instance_releases_one_refcount(self, tmp_path):
"""Legacy ``db.close()`` call sites must not leak refcounts: close()
on a shared instance releases ONE reference — so the gateway's
pre-registry close paths stay balanced — while never tearing down
the connection other holders still use."""
db_path = tmp_path / "state.db"
a = registry.acquire(db_path)
b = registry.acquire(db_path)
assert a is b
# Legacy close: decrements, does not tear down (b still holds).
a.close()
assert b._conn is not None, "close() must not tear down a shared instance"
# The refcount is now 1 (b's); releasing b tears down.
assert registry.release(b) is True
assert b._conn is None
stats = registry.stats()
assert stats["live_generations"] == 0
def test_close_only_call_site_does_not_leak_refcount(self, tmp_path):
"""A call site that acquires and only calls close() (the pre-#90837
cleanup idiom) must return its reference — the exact leak class
the 4-angle review flagged."""
db_path = tmp_path / "state.db"
for _ in range(5):
db = registry.acquire(db_path)
db.close()
stats = registry.stats()
assert stats["live_generations"] == 0, (
f"acquire+close cycles leaked refcounts: {stats}"
)
assert stats["retired_generations"] == 0
class TestAcquireSingleFlight:
def test_concurrent_first_acquires_share_one_generation(self, tmp_path, monkeypatch):
"""Two threads acquiring a cold path concurrently must end up
sharing ONE generation, with the loser's instance torn down."""
db_path = tmp_path / "state.db"
real_open = registry._open_session_db
gate = threading.Event()
opened = []
def _gated_open(path):
db = real_open(path)
opened.append(db)
# Hold the first open so a second thread can race in.
if len(opened) == 1:
gate.wait(5.0)
return db
monkeypatch.setattr(registry, "_open_session_db", _gated_open)
results = []
errors = []
def _acquire():
try:
results.append(registry.acquire(db_path))
except Exception as exc: # pragma: no cover
errors.append(exc)
t1 = threading.Thread(target=_acquire)
t1.start()
# Wait until the first open is in flight inside the lock window.
deadline = time.monotonic() + 5.0
while not opened and time.monotonic() < deadline:
time.sleep(0.01)
t2 = threading.Thread(target=_acquire)
t2.start()
gate.set()
t1.join(10.0)
t2.join(10.0)
assert errors == []
assert len(results) == 2
assert results[0] is results[1], "concurrent acquires must share one generation"
assert len(opened) >= 1
registry.release(results[0])
registry.release(results[1])
@@ -47,7 +47,7 @@ class TestCronJobCleanup:
"model": "test/model",
}
with patch("hermes_state.SessionDB", return_value=mock_db), \
with patch("hermes_state.get_shared_session_db", return_value=mock_db), \
patch.object(scheduler, "_build_job_prompt", return_value="hello"), \
patch.object(scheduler, "_resolve_origin", return_value=None), \
patch.object(scheduler, "_resolve_delivery_target", return_value=None), \
@@ -68,6 +68,7 @@ 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)
session_search_mod = ModuleType("tools.session_search_tool")
+25 -15
View File
@@ -3937,7 +3937,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.SessionDB", lambda db_path=None: profile_db)
monkeypatch.setattr("hermes_state.get_shared_session_db", 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: [])
@@ -4001,7 +4001,7 @@ def test_session_cwd_set_profile_session_updates_profile_db(monkeypatch, tmp_pat
import tools.terminal_tool as terminal_tool
monkeypatch.setattr("hermes_state.SessionDB", lambda db_path=None: profile_db)
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda db_path=None: profile_db)
monkeypatch.setattr(server, "_get_db", lambda: LaunchDB())
monkeypatch.setattr(terminal_tool, "cleanup_vm", lambda _key: None)
monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None)
@@ -7755,7 +7755,7 @@ def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path):
def close(self):
pass
monkeypatch.setattr("hermes_state.SessionDB", _ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", _ProfileDB)
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row(
@@ -9591,7 +9591,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.SessionDB", FakeDb)
monkeypatch.setattr("hermes_state.get_shared_session_db", FakeDb)
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
monkeypatch.setattr(server, "_transfer_db_to_agent", barrier_transfer)
monkeypatch.setattr(
@@ -14402,6 +14402,11 @@ def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch):
raise RuntimeError("locking protocol")
fake_mod.SessionDB = _BrokenSessionDB
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)
monkeypatch.setattr(server, "_db", None)
monkeypatch.setattr(server, "_db_error", None)
@@ -14420,6 +14425,11 @@ def test_ensure_session_db_row_false_when_store_unavailable(monkeypatch):
raise RuntimeError("utf-8 boom")
fake_mod.SessionDB = _BrokenSessionDB
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)
monkeypatch.setattr(server, "_db", None)
monkeypatch.setattr(server, "_db_error", None)
@@ -14772,7 +14782,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
resp = server.handle_request(
{
@@ -14813,7 +14823,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.SessionDB", ProfileDB2)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB2)
resp = server.handle_request(
{
@@ -14873,7 +14883,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
resp = server.handle_request(
{
@@ -14940,7 +14950,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
try:
set_resp = server.handle_request(
{
@@ -14997,7 +15007,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
try:
resp = server.handle_request(
{"id": "1", "method": "session.history", "params": {"session_id": "sid"}}
@@ -15084,7 +15094,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
try:
resp = server.handle_request(
{"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
@@ -15126,7 +15136,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
session = {
"session_key": "ml-sess",
"profile_home": str(profile_home),
@@ -15219,7 +15229,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
def _fake_make_agent(*a, **k):
@@ -15335,7 +15345,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None))
def _fake_make_agent(*a, **k):
@@ -15452,7 +15462,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", 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: {})
@@ -15512,7 +15522,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.SessionDB", ProfileDB)
monkeypatch.setattr("hermes_state.get_shared_session_db", ProfileDB)
session = {
"session_key": "ml-sess",
"pending_title": "deferred-title",
+8 -7
View File
@@ -114,23 +114,24 @@ class TestFormatTimestamp:
# =========================================================================
class TestBrowseShape:
def test_lazy_database_is_closed_after_search(self, monkeypatch):
def test_lazy_database_is_released_after_search(self, monkeypatch):
class _DB:
closed = 0
released = 0
def list_sessions_rich(self, **_kwargs):
return []
def close(self):
self.closed += 1
db = _DB()
monkeypatch.setattr("hermes_state.SessionDB", lambda: db)
monkeypatch.setattr("hermes_state.get_shared_session_db", lambda: db)
monkeypatch.setattr(
"hermes_state.release_or_close",
lambda _: setattr(db, "released", db.released + 1),
)
result = json.loads(session_search())
assert result["success"] is True
assert db.closed == 1
assert db.released == 1
def test_cross_profile_database_is_closed_but_shared_database_is_not(
self, monkeypatch
@@ -178,7 +178,7 @@ def test_lazy_recall_open_is_owned_by_the_agent(monkeypatch):
opened.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _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.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
for name, value in [
("_set_session_context", lambda _key: []),
("_clear_session_context", lambda _tokens: None),
@@ -90,7 +90,7 @@ def profile_dbs(monkeypatch, tmp_path):
opened.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
monkeypatch.setattr(
server, "_profile_home", lambda profile: profile_home if profile else None
)
@@ -146,7 +146,7 @@ def test_deferred_desktop_resume_keeps_stored_workspace_provenance(
profile_dbs.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
resp = _resume(session_id="s1", profile="work", source="desktop")
session = server._sessions[resp["result"]["session_id"]]
@@ -166,7 +166,7 @@ def test_resume_closes_profile_db_when_reopen_fails(profile_dbs, monkeypatch):
profile_dbs.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
resp = _resume(session_id="s1", profile="work")
@@ -189,7 +189,7 @@ def test_resume_closes_profile_db_on_live_session_fast_path(profile_dbs, monkeyp
profile_dbs.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
live_session = {}
with server._sessions_lock:
server._sessions["live-sid"] = live_session
@@ -225,7 +225,7 @@ def test_resume_closes_profile_db_on_deferred_cold_resume(profile_dbs, monkeypat
profile_dbs.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {})
resp = _resume(session_id="s1", profile="work")
@@ -258,7 +258,7 @@ def test_resume_hands_profile_db_to_deferred_history_worker(profile_dbs, monkeyp
profile_dbs.append(db)
return db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
monkeypatch.setattr(server, "_stored_session_runtime_overrides", lambda _found: {})
monkeypatch.setattr(server, "_start_agent_build", lambda *_args, **_kwargs: None)
@@ -300,7 +300,7 @@ def test_resume_keeps_profile_db_open_after_ownership_transfer(profile_dbs, monk
def _fake_init_session(sid, key, agent, history, session_db=None, **_kwargs):
captured["init_db"] = session_db
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
monkeypatch.setattr(server, "_make_agent", _fake_make_agent)
monkeypatch.setattr(server, "_init_session", _fake_init_session)
monkeypatch.setattr(server, "_set_session_context", lambda _target: [])
@@ -345,7 +345,7 @@ def test_resume_drops_half_built_session_when_init_session_raises(
server._sessions[sid] = {"agent": agent, "session_key": key}
raise RuntimeError("database is locked")
monkeypatch.setattr("hermes_state.SessionDB", _factory)
monkeypatch.setattr("hermes_state.get_shared_session_db", _factory)
monkeypatch.setattr(
server, "_make_agent", lambda *a, **k: types.SimpleNamespace(model="test")
)
+5 -4
View File
@@ -2049,13 +2049,13 @@ def _build_child_agent(
parent_session_db = getattr(parent_agent, "_session_db", None)
if parent_session_db is not None:
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
_parent_db_path = getattr(parent_session_db, "db_path", None)
child_session_db = (
SessionDB(db_path=_parent_db_path)
get_shared_session_db(_parent_db_path)
if _parent_db_path is not None
else SessionDB()
else get_shared_session_db()
)
except Exception:
logger.debug(
@@ -2124,7 +2124,8 @@ def _build_child_agent(
# don't outlive the failed spawn.
if child_session_db is not None:
try:
child_session_db.close()
from hermes_state import release_or_close
release_or_close(child_session_db)
except Exception:
pass
raise
+4 -3
View File
@@ -24,9 +24,9 @@ from utils import env_var_enabled
def _open_session_db():
"""Open the SessionDB for the profile owning this turn, or ``None``."""
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
return SessionDB()
return get_shared_session_db()
except Exception:
return None
@@ -111,7 +111,8 @@ def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -
)
finally:
try:
db.close()
from hermes_state import release_or_close
release_or_close(db)
except Exception:
pass
+4 -3
View File
@@ -1100,9 +1100,9 @@ def session_search(
owned_dbs: List[Any] = []
if db is None:
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
db = SessionDB()
db = get_shared_session_db()
owned_dbs.append(db)
except Exception:
logging.debug("SessionDB unavailable for session_search", exc_info=True)
@@ -1128,7 +1128,8 @@ def session_search(
finally:
for owned_db in reversed(owned_dbs):
try:
owned_db.close()
from hermes_state import release_or_close
release_or_close(owned_db)
except Exception:
logging.debug("Failed to close session_search SessionDB", exc_info=True)
+4 -2
View File
@@ -609,7 +609,8 @@ class ComputeHost:
# server._sessions[sid] (via _init_session, or the fallback dict
# in the except below), so the agent is the right owner; a
# _make_agent that RAISES is the one path where nothing takes it.
session_db = SessionDB(db_path=Path(profile_home) / "state.db")
from hermes_state import get_shared_session_db
session_db = get_shared_session_db(Path(profile_home) / "state.db")
owns_db = True
agent = server._make_agent(
sid,
@@ -629,7 +630,8 @@ class ComputeHost:
finally:
if owns_db and session_db is not None:
with contextlib.suppress(Exception):
session_db.close()
from hermes_state import release_or_close
release_or_close(session_db)
if home_token is not None:
try:
from hermes_constants import reset_hermes_home_override
+4 -2
View File
@@ -187,12 +187,14 @@ def _(rid, params: dict) -> dict:
from pathlib import Path
wdb = SessionDB(db_path=Path(profile_path) / "state.db")
from hermes_state import get_shared_session_db
wdb = get_shared_session_db(Path(profile_path) / "state.db")
try:
return bool(wdb.unarchive_recoverable_session(session_id))
finally:
try:
wdb.close()
from hermes_state import release_or_close
release_or_close(wdb)
except Exception:
pass
except Exception:
+8 -5
View File
@@ -401,9 +401,9 @@ def _(rid, params: dict) -> dict:
# shared launch db, which outlives the RPC and is never closed here.
owns_db = False
if profile_home is not None:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
db = SessionDB(db_path=profile_home / "state.db")
db = get_shared_session_db(profile_home / "state.db")
owns_db = True
else:
db = _get_db()
@@ -461,7 +461,8 @@ def _(rid, params: dict) -> dict:
if live is not None:
if owns_db:
with contextlib.suppress(Exception):
db.close()
from hermes_state import release_or_close
release_or_close(db)
live["last_active"] = time.time()
# This resume reattaches the live record. A lazy session
# (no state.db row yet — every fresh Bot Chat) that was
@@ -3301,7 +3302,8 @@ def _(rid, params: dict) -> dict:
# DEDICATED handle, same ownership rule as session.resume: ours
# until the branched agent takes it below. _make_agent raising, or
# _init_session raising, both leave here without that transfer.
branch_db = SessionDB(db_path=Path(parent_home) / "state.db")
from hermes_state import get_shared_session_db
branch_db = get_shared_session_db(Path(parent_home) / "state.db")
branch_owns_db = True
home_token = (
set_hermes_home_override(parent_home) if parent_home else None
@@ -3364,7 +3366,8 @@ def _(rid, params: dict) -> dict:
finally:
if branch_owns_db and branch_db is not None:
with contextlib.suppress(Exception):
branch_db.close()
from hermes_state import release_or_close
release_or_close(branch_db)
branched_session = _sessions.get(new_sid)
return _ok(
rid,
+14 -10
View File
@@ -2216,10 +2216,10 @@ _start_idle_reaper()
def _get_db():
global _db, _db_error
if _db is None:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
try:
_db = SessionDB()
_db = get_shared_session_db()
_db_error = None
except Exception as exc:
_db_error = str(exc)
@@ -2245,9 +2245,9 @@ def _db_for_profile(profile: str | None = None):
if profile_home is None:
return _get_db(), False
try:
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
return SessionDB(db_path=Path(profile_home) / "state.db"), True
return get_shared_session_db(Path(profile_home) / "state.db"), True
except Exception as exc:
logger.warning(
"TUI profile session store unavailable for %s: %s",
@@ -2309,11 +2309,11 @@ def _open_profile_session_db(profile_home):
the build's ``agent_error`` path) instead of swallowing it back onto the
launch handle.
"""
from hermes_state import SessionDB
from hermes_state import get_shared_session_db
db_path = Path(profile_home) / "state.db"
try:
return SessionDB(db_path=db_path)
return get_shared_session_db(db_path)
except Exception as exc:
raise RuntimeError(
f"profile session store unavailable: {db_path}: {exc}"
@@ -3938,7 +3938,8 @@ def _ensure_session_db_row(session: dict) -> bool:
from hermes_state import SessionDB
try:
db = SessionDB(db_path=Path(profile_home) / "state.db")
from hermes_state import get_shared_session_db
db = get_shared_session_db(Path(profile_home) / "state.db")
except Exception:
logger.debug("failed to open profile db for session row", exc_info=True)
return False
@@ -4068,7 +4069,8 @@ def _ensure_session_db_row(session: dict) -> bool:
finally:
if close_db:
try:
db.close()
from hermes_state import release_or_close
release_or_close(db)
except Exception:
pass
return True
@@ -4152,7 +4154,8 @@ def _session_db(session: dict):
from hermes_state import SessionDB
try:
db, close_db = SessionDB(db_path=Path(profile_home) / "state.db"), True
from hermes_state import get_shared_session_db
db, close_db = get_shared_session_db(Path(profile_home) / "state.db"), True
except Exception:
logger.debug("failed to open profile db for session", exc_info=True)
else:
@@ -4162,7 +4165,8 @@ def _session_db(session: dict):
finally:
if close_db and db is not None:
with contextlib.suppress(Exception):
db.close()
from hermes_state import release_or_close
release_or_close(db)
def _rewind_active_session_history(