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:
+7
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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()), \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}'),
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user