Skip to content
Open
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
136 changes: 136 additions & 0 deletions tests/tools/test_session_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
All run zero LLM calls.
"""
import json
import sqlite3
import time

import pytest
Expand All @@ -18,6 +19,7 @@
_format_timestamp,
_is_compacted_message,
_is_compression_ended,
_open_cross_profile_session_db,
_resolve_to_parent,
_session_link,
session_search,
Expand Down Expand Up @@ -405,6 +407,140 @@ def test_combined_value_autosplits(self, db, tmp_path, monkeypatch):
assert result["session_id"] == "s_other"


class TestCrossProfileStaleSchemaHeal:
"""A profile's state.db that predates a schema addition (untouched since
the last `hermes update`) must still be readable cross-profile --
dashboard reads get this via hermes_cli.web_server's probe-then-heal;
session_search's cross-profile paths (profile= browse, bare-id locate
scan) previously bypassed it entirely and hard-failed on ANY column
SCHEMA_SQL has added since that profile's store was created."""

def _stale_db(self, tmp_path, session_id="stale-session"):
home = tmp_path / f"{session_id}_home"
home.mkdir()
db_path = home / "state.db"
seed = SessionDB(db_path=db_path)
try:
seed.create_session(session_id, source="cli")
seed.append_message(session_id, role="user", content="hi")
finally:
seed.close()

legacy = sqlite3.connect(str(db_path))
try:
legacy.execute("ALTER TABLE sessions DROP COLUMN last_activity_at")
legacy.commit()
finally:
legacy.close()
return home, db_path

def test_browse_shape_heals_stale_schema_in_other_profile(
self, db, tmp_path, monkeypatch
):
"""BROWSE mode (session_search(profile=...), no query) reaches
list_sessions_rich, which selects sessions.last_activity_at -- the
exact column whose absence originally broke the dashboard sidebar
(#72424 aftermath). Before this fix, a stale other-profile store
made every browse of it fail with "no such column"."""
from hermes_cli import profiles as profiles_mod

home, _db_path = self._stale_db(tmp_path, "browse-stale")
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
monkeypatch.setattr(profiles_mod, "validate_profile_name", lambda n: None)
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: True)
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: home)

result = json.loads(session_search(profile="asdf", db=db))

assert result["success"] is True, result
assert result["results"][0]["session_id"] == "browse-stale"

def test_locate_scan_heals_stale_schema_in_other_profile(
self, db, tmp_path, monkeypatch
):
"""The bare-id cross-profile locate scan (_locate_session_db) opens
every profile's store read-only too -- same heal contract applies
for defense-in-depth. get_session/get_messages happen not to select
sessions.last_activity_at specifically, so this exact column drop
doesn't reproduce a crash on THIS call chain the way it does on
the browse path below -- this pins the wiring (same heal helper,
same failure-tolerant open) against a future column that does."""
from collections import namedtuple
from hermes_cli import profiles as profiles_mod

home, _db_path = self._stale_db(tmp_path, "locate-stale")
Info = namedtuple("Info", "name path")
monkeypatch.setattr(
profiles_mod, "get_profile_dir", lambda n: tmp_path / "default_home"
)
monkeypatch.setattr(
profiles_mod, "list_profiles", lambda: [Info("stale-owner", home)]
)

result = json.loads(session_search(session_id="locate-stale", db=db))

assert result["success"] is True, result
assert result["profile"] == "stale-owner"

def test_open_cross_profile_session_db_heals_and_memoizes_exhaustion(
self, tmp_path, monkeypatch
):
"""Direct unit coverage of the heal helper's give-up path, mirroring
hermes_cli.web_server's own test for _open_session_db_at_path: a
probe failure reconciliation cannot fix must not retry the writable
open on every subsequent call for that store."""
import tools.session_search_tool as sst

db_path = tmp_path / "unfixable" / "state.db"
db_path.parent.mkdir()
seed = SessionDB(db_path=db_path)
try:
seed.create_session("unfixable", source="cli")
finally:
seed.close()

monkeypatch.setattr(sst, "_cross_profile_db_heal_exhausted", set())
monkeypatch.setattr(sst, "_cross_profile_db_heal_warned", set())

import hermes_state_schema

monkeypatch.setattr(
hermes_state_schema,
"schema_read_probe_statements",
lambda: (
'SELECT "sessions"."not_a_real_column" FROM "sessions" LIMIT 0',
),
)

writable_opens = []
original_init = SessionDB.__init__

def counting_init(self, *args, **kwargs):
if not kwargs.get("read_only", False):
writable_opens.append(1)
return original_init(self, *args, **kwargs)

monkeypatch.setattr(SessionDB, "__init__", counting_init)

# First open: probe fails -> one writable heal -> re-probe fails ->
# exhausted. Still returns a usable read-only handle.
db1 = _open_cross_profile_session_db(db_path)
try:
assert db1.get_session("unfixable") is not None
finally:
db1.close()
assert len(writable_opens) == 1
assert str(db_path) in sst._cross_profile_db_heal_exhausted

# Second open: probe skipped, no further writable opens.
db2 = _open_cross_profile_session_db(db_path)
try:
assert db2.get_session("unfixable") is not None
finally:
db2.close()
assert len(writable_opens) == 1


# =========================================================================
# Cron demotion in discover ranking (#19434)
# =========================================================================
Expand Down
92 changes: 88 additions & 4 deletions tools/session_search_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import json
import logging
import threading
from typing import Any, Dict, List, Optional, Union

# Sources that are excluded from session browsing/searching by default.
Expand Down Expand Up @@ -295,6 +296,91 @@ def _shape_message(
return {k: v for k, v in entry.items() if v is not None or k in ("content",)}


# Heal state for _open_cross_profile_session_db, mirroring
# hermes_cli.web_server._open_session_db_at_path's contract — NOT shared
# with it: the dashboard and agent are separate OS processes in normal
# operation, so there is no process memory to coordinate a single registry
# through. Each side owns its own bootstrap-lock/heal-exhausted state.
_cross_profile_db_bootstrap_lock = threading.Lock()
_cross_profile_db_heal_exhausted: set = set()
_cross_profile_db_heal_warned: set = set()


def _open_cross_profile_session_db(db_path):
"""Open another profile's ``state.db`` read-only, healing a stale schema.

Read-only opens skip SessionDB's column-reconciliation path by design —
a read-only handle must never take a write lock against a possibly-live
store. A profile whose store predates a schema addition (e.g. one
untouched since the last ``hermes update``) then raises "no such
column"/"no such table" on any query touching the new shape. Mirrors
``hermes_cli.web_server._open_session_db_at_path``'s probe-then-one-time-
writable-reopen heal (derived from SCHEMA_SQL, so any future column
addition is covered automatically) so cross-profile session reads here —
``session_search(profile=...)`` and the ``@session:<profile>/<id>`` link
fallback scan — get the same guarantee dashboard reads already do.
"""
import sqlite3

from hermes_state import SessionDB, is_malformed_db_error
from hermes_state_schema import schema_read_probe_statements

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

if _needs_bootstrap():
with _cross_profile_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)
conn = getattr(db, "_conn", None)
if conn is not None and str(db_path) not in _cross_profile_db_heal_exhausted:
try:
for statement in schema_read_probe_statements():
conn.execute(statement).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()
try:
return _open_probed()
except sqlite3.DatabaseError as still_stale:
message = str(still_stale).lower()
if "no such table" not in message and "no such column" not in message:
raise
# The writable open succeeded but the store is STILL behind the
# probe: reconciliation cannot fix this one. Serve reads without
# the probe (queries touching the broken part will still fail,
# everything else works) and stop paying the writable init per
# call.
_cross_profile_db_heal_exhausted.add(str(db_path))
if str(db_path) not in _cross_profile_db_heal_warned:
_cross_profile_db_heal_warned.add(str(db_path))
logging.getLogger(__name__).warning(
"state.db at %s is missing schema that a writable "
"reconcile could not add (%s); session_search reads "
"may partially fail until the store is repaired",
db_path, still_stale,
)
return _open_probed()


def _resolve_profile_db(profile: str):
"""Open another profile's ``state.db`` read-only, or None for the current one.

Expand All @@ -308,14 +394,13 @@ def _resolve_profile_db(profile: str):
return None

from hermes_cli import profiles as profiles_mod
from hermes_state import SessionDB

canon = profiles_mod.normalize_profile_name(profile)
profiles_mod.validate_profile_name(canon)
if not profiles_mod.profile_exists(canon):
raise ValueError(f"profile '{canon}' does not exist")

return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True)
return _open_cross_profile_session_db(profiles_mod.get_profile_dir(canon) / "state.db")


def _session_link(session_id: str, profile: str = None) -> str:
Expand Down Expand Up @@ -353,7 +438,6 @@ def _locate_session_db(session_id: str):

try:
from hermes_cli import profiles as profiles_mod
from hermes_state import SessionDB
except Exception:
return None, None

Expand All @@ -371,7 +455,7 @@ def _locate_session_db(session_id: str):
continue
seen.add(key)
try:
pdb = SessionDB(db_path=db_path, read_only=True)
pdb = _open_cross_profile_session_db(db_path)
except Exception:
continue
try:
Expand Down
Loading