Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions hermes_cli/web_routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ def get_sessions(
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
db = _open_session_db_for_profile(profile)
# Auto-archive is the only configured write on this GET path. Run it
# through a dedicated maintenance connection, close that writer, then
# open the listing connection read-only.
_maybe_auto_archive_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
# Opportunistic, config-gated, double-throttled stale-session
# sweep — the only auto_archive hook that fires for Desktop's
# `hermes serve` backend. No-op when disabled or run recently.
_maybe_auto_archive_for_profile(db, profile)
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
Expand Down Expand Up @@ -182,7 +182,7 @@ async def search_sessions(
if not q or not q.strip():
return {"results": []}
try:
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
safe_limit = max(1, min(int(limit or 20), 100))
source_filter = source or None
Expand Down Expand Up @@ -421,7 +421,7 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
detail="ids must contain at most 500 entries",
)
def _delete() -> int:
db = _open_session_db_for_profile(body.profile)
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
return db.delete_sessions(body.ids)
finally:
Expand Down Expand Up @@ -466,7 +466,7 @@ async def count_empty_sessions_endpoint(profile: Optional[str] = None):
that does nothing. Cheap, single-COUNT query.
"""
def _count() -> int:
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
return db.count_empty_sessions()
finally:
Expand Down Expand Up @@ -496,7 +496,7 @@ async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
def _delete() -> int:
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=False)
try:
return db.delete_empty_sessions()
finally:
Expand All @@ -513,7 +513,7 @@ async def get_session_stats(profile: Optional[str] = None):
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
Expand All @@ -540,7 +540,7 @@ async def get_session_stats(profile: Optional[str] = None):

@manage_router.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = db.resolve_session_id(session_id)
session = db.get_session(sid) if sid else None
Expand All @@ -567,7 +567,7 @@ async def get_session_latest_descendant(
profile: Optional[str] = None,
):
def _lookup():
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
return _session_latest_descendant(session_id, db)
finally:
Expand All @@ -592,7 +592,7 @@ async def get_session_messages(
offset: int = Query(0, ge=0),
):
def _read():
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = db.resolve_session_id(session_id)
if not sid:
Expand Down Expand Up @@ -625,7 +625,7 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None
# opening its state.db directly. Remote profiles never reach here — the
# desktop routes their DELETE to the remote backend. Omit for current/default.
def _delete():
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=False)
try:
# Resolve exact ids / unique prefixes like every other session endpoint
# (detail, messages, rename, export all do). A session that no longer
Expand Down Expand Up @@ -656,7 +656,7 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
session from the auto-archive sweep). Any field may be omitted. ``profile``
targets another profile's session.
"""
db = _open_session_db_for_profile(body.profile)
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
sid = db.resolve_session_id(session_id)
if not sid:
Expand Down Expand Up @@ -690,7 +690,7 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Export a single session (metadata + messages) as JSON."""
def _export():
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = db.resolve_session_id(session_id)
return db.export_session(sid) if sid else None
Expand Down
118 changes: 91 additions & 27 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10989,7 +10989,7 @@ async def _read_session_import_body(request: Request) -> bytes:


def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=False)
try:
return db.import_sessions(sessions)
finally:
Expand Down Expand Up @@ -11021,19 +11021,82 @@ def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str



def _open_session_db_for_profile(profile: Optional[str]):
"""Open a SessionDB for read paths, optionally for another profile.
# Serialises the one-time writable schema bootstrap for read-only opens.
# Concurrent first-load polls otherwise race sqlite file creation: the losers
# open mode=ro against a store whose schema is still being written and every
# query raises "no such table: sessions".
_session_db_bootstrap_lock = threading.Lock()

``profile`` None/empty → this process's own ``state.db`` (the common,
single-profile case). A named profile opens that profile's on-disk
``state.db`` directly so the primary backend can serve cross-profile reads
(transcripts, detail) without spawning that profile's backend.
# Stale-schema probe for read-only opens: compiled against the newest columns
# the dashboard read paths query. Reads at most one row per table. Read-only
# opens skip _reconcile_columns(), so an older store would otherwise 500 on
# every poll until something opened it writable.
_SESSION_DB_READ_PROBE_SQL = (
"SELECT (SELECT archived FROM sessions LIMIT 1), "
"(SELECT pinned FROM sessions LIMIT 1), "
"(SELECT active FROM messages LIMIT 1), "
"(SELECT compacted FROM messages LIMIT 1)"
)


def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool):
"""Open a SessionDB with an explicit access mode for a profile.

``profile`` None/empty selects this process's own ``state.db``. A named
profile opens that profile's on-disk store directly.

Writable opens keep the full init and repair path. Read-only opens
bootstrap a missing or zero-byte store once, and heal an older or
malformed schema through one writable open before reopening read-only.
The healthy read path never takes a write lock or requests a checkpoint.
"""
from hermes_state import SessionDB
if not profile:
return SessionDB()
_name, home = _cron_profile_home(profile)
return SessionDB(db_path=Path(home) / "state.db")
import sqlite3

from hermes_state import SessionDB, _default_db_path, is_malformed_db_error

if profile:
_name, home = _cron_profile_home(profile)
db_path = Path(home) / "state.db"
else:
db_path = Path(_default_db_path())
if not read_only:
return SessionDB(db_path=db_path, read_only=False)

def _needs_bootstrap() -> bool:
try:
return db_path.stat().st_size == 0
except FileNotFoundError:
return True
except OSError:
return False

if _needs_bootstrap():
with _session_db_bootstrap_lock:
if _needs_bootstrap():
SessionDB(db_path=db_path, read_only=False).close()

def _open_probed():
db = SessionDB(db_path=db_path, read_only=True)
# Unit-test fakes may replace SessionDB without exposing a raw
# connection. Probe only real connections.
conn = getattr(db, "_conn", None)
if conn is not None:
try:
conn.execute(_SESSION_DB_READ_PROBE_SQL).fetchone()
except BaseException:
db.close()
raise
return db

try:
return _open_probed()
except sqlite3.DatabaseError as exc:
message = str(exc).lower()
stale_schema = "no such table" in message or "no such column" in message
if not stale_schema and not is_malformed_db_error(exc):
raise
SessionDB(db_path=db_path, read_only=False).close()
return _open_probed()


# In-process throttle for the opportunistic auto-archive trigger, keyed by
Expand All @@ -11044,7 +11107,7 @@ def _open_session_db_for_profile(profile: Optional[str]):
_last_auto_archive_check: Dict[str, float] = {}


def _maybe_auto_archive_for_profile(db, profile: Optional[str]) -> None:
def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
"""Run the config-gated stale-session auto-archive for ``profile``.

The Desktop backend is spawned as ``hermes serve`` — it runs neither the
Expand All @@ -11065,10 +11128,14 @@ def _maybe_auto_archive_for_profile(db, profile: Optional[str]) -> None:
cfg = (_load_full_config().get("sessions") or {})
if not cfg.get("auto_archive", False):
return
db.maybe_auto_archive(
idle_days=float(cfg.get("auto_archive_days", 3)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
)
db = _open_session_db_for_profile(profile, read_only=False)
try:
db.maybe_auto_archive(
idle_days=float(cfg.get("auto_archive_days", 3)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
)
finally:
db.close()
except Exception as exc:
_log.debug("opportunistic auto-archive skipped: %s", exc)

Expand All @@ -11086,11 +11153,7 @@ async def _auto_archive_ticker_loop(
"""

def _sweep() -> None:
db = _open_session_db_for_profile(None)
try:
_maybe_auto_archive_for_profile(db, None)
finally:
db.close()
_maybe_auto_archive_for_profile(None)

await asyncio.sleep(initial_delay_s)
while True:
Expand Down Expand Up @@ -11138,7 +11201,7 @@ def _prune_sessions(body: SessionPrune):
if has_window or (_attr_filters_set and not _older_than_explicit):
_effective_older_than = None
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
db = _open_session_db_for_profile(body.profile)
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
filters = dict(
older_than_days=_effective_older_than,
Expand Down Expand Up @@ -11563,7 +11626,7 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit:
except (TypeError, ValueError):
limit_n = 20

db = _open_session_db_for_profile(selected)
db = _open_session_db_for_profile(selected, read_only=True)
try:
runs = db.list_cron_job_runs(canonical, limit=limit_n, offset=0)
now = time.time()
Expand Down Expand Up @@ -13891,7 +13954,7 @@ def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def _get_usage_analytics(days: int = 30, profile: Optional[str] = None):
from agent.insights import InsightsEngine

db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
cutoff = time.time() - (days * 86400)
cur = db._conn.execute("""
Expand Down Expand Up @@ -13980,7 +14043,7 @@ def _get_models_analytics(days: int = 30, profile: Optional[str] = None):
Returns token/cost/session breakdown per model plus capability metadata
from models.dev (context window, vision, tools, reasoning, etc.).
"""
db = _open_session_db_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
cutoff = time.time() - (days * 86400)

Expand Down Expand Up @@ -14623,7 +14686,8 @@ def _resolve_chat_argv(

if resume:
_resume_db = _open_session_db_for_profile(
requested if profile_dir is not None else None
requested if profile_dir is not None else None,
read_only=True,
)
try:
latest_resume, _latest_path = _session_latest_descendant(resume, _resume_db)
Expand Down
46 changes: 40 additions & 6 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,35 @@ def __init__(self, db_path: Path = None, read_only: bool = False):
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
# FTS capability flags normally come from writable schema
# initialisation. Probe existing virtual tables with SELECTs
# only so read-only search keeps its FTS and trigram paths.
# Close the connection on ANY probe failure (e.g. malformed
# schema raises DatabaseError, not the OperationalError the
# probe handles): the outer except re-raises without cleanup,
# and a leaked tracked connection blocks _backup_db_file's
# raw-copy for the rest of the process — the writable heal
# that follows would then repair WITHOUT its forensic backup.
try:
cursor = self._conn.cursor()
self._fts_enabled = (
self._fts_table_probe(cursor, "messages_fts") is True
)
if self._fts_enabled:
self._trigram_available = (
self._fts_table_probe(
cursor,
"messages_fts_trigram",
)
is True
)
except BaseException:
conn, self._conn = self._conn, None
try:
conn.close()
except Exception:
pass
raise
return

self.db_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -2627,8 +2656,9 @@ def close(self):
"""Close the database connection.

Drains queued token deltas first (the background writer needs the
connection), then attempts a TRUNCATE WAL checkpoint so that
exiting processes help shrink the WAL file.
connection). Writable connections then attempt a TRUNCATE WAL
checkpoint so exiting writer processes help shrink the WAL file.
Read-only connections never request a checkpoint.
"""
self._stop_token_writer()
# The atexit hook holds a strong reference to this instance (bound
Expand Down Expand Up @@ -2656,10 +2686,14 @@ def close(self):
self._read_local.conn = None
with self._lock:
if self._conn:
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception as exc:
logger.debug("WAL checkpoint (TRUNCATE) at close failed: %s", exc)
if not self.read_only:
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception as exc:
logger.debug(
"WAL checkpoint (TRUNCATE) at close failed: %s",
exc,
)
self._conn.close()
self._conn = None

Expand Down
Loading
Loading