diff --git a/api/agent_sessions.py b/api/agent_sessions.py index ed3ab538..c7d4c7aa 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -576,7 +576,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: # expects from "Show agent sessions" sorted by activity. for key in ( 'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count', - 'ended_at', 'end_reason', 'last_activity', + 'ended_at', 'end_reason', 'last_activity', 'archived', ): if key in tip: merged[key] = tip[key] @@ -683,6 +683,7 @@ def read_importable_agent_session_rows( origin_chat_id_expr = _optional_col('origin_chat_id', session_cols) origin_user_id_expr = _optional_col('origin_user_id', session_cols) platform_expr = _optional_col('platform', session_cols) + archived_expr = _optional_col('archived', session_cols) # Older/minimal state.db schemas can have NO ``messages`` table at all, # or a ``messages`` table without a ``session_id`` / ``timestamp`` column. # The projection SQL below joins ``messages`` and aggregates @@ -793,6 +794,7 @@ def read_importable_agent_session_rows( {parent_expr}, {ended_expr}, {end_reason_expr}, + {archived_expr}, {actual_count_expr} AS actual_message_count, {user_message_count_expr} AS actual_user_message_count, {last_activity_expr} AS last_activity diff --git a/api/models.py b/api/models.py index c9c0318a..7b3520ad 100644 --- a/api/models.py +++ b/api/models.py @@ -8073,7 +8073,7 @@ def _state_projection_sidecar_metadata(sid: str) -> dict: stops being true (metadata moves to another store), this gate would short- circuit before the real source — update both together. """ - default = {"title": None, "archived": False} + default = {"title": None, "archived": None} if not is_safe_session_id(sid): return dict(default) p = SESSION_DIR / f'{sid}.json' @@ -8270,7 +8270,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) _display_title = _title or f'{_source.title()} Session' cli_sessions.append({ 'session_id': sid, @@ -8342,7 +8345,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) _display_title = _title or 'Cron Session' cli_sessions.append({ 'session_id': sid, @@ -8408,7 +8414,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) _display_title = _title or 'Webhook Session' cli_sessions.append({ 'session_id': sid, @@ -8473,7 +8482,10 @@ def _load_cli_sessions_uncached( _sidecar_meta = _state_projection_sidecar_metadata(sid) if _sidecar_meta.get('title'): _title = _sidecar_meta['title'] - _archived = bool(_sidecar_meta.get('archived')) + if _sidecar_meta.get('archived') is not None: + _archived = bool(_sidecar_meta['archived']) + else: + _archived = bool(row.get('archived')) cli_sessions.append({ 'session_id': sid, 'title': _title or 'Kanban Session', diff --git a/api/route_session_list_cache.py b/api/route_session_list_cache.py index b91ba7b8..e382a287 100644 --- a/api/route_session_list_cache.py +++ b/api/route_session_list_cache.py @@ -606,6 +606,22 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple session_index_path = _session_list_cache_session_dir() / "_index.json" except Exception: session_index_path = None + all_profiles_state_stamp = None + if _cache_all_profiles: + try: + from api.models import _all_profiles_cli_contexts + contexts, _ = _all_profiles_cli_contexts() + all_profiles_state_stamp = tuple( + ( + _session_list_cache_path_stamp(ctx_db), + _session_list_cache_path_stamp(ctx_db.with_name(f"{ctx_db.name}-wal") if ctx_db else None), + _session_list_cache_state_db_fingerprint(ctx_db), + ) + for _home, ctx_db, _prof in contexts + ) + except Exception: + all_profiles_state_stamp = None + return ( _session_list_cache_path_stamp(state_db_path), _session_list_cache_path_stamp(state_db_wal_path), @@ -617,6 +633,7 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple # frame size), so without this a freshly-committed CLI/gateway session # could be served stale for the cache TTL. Mirrors the models-layer fix. _session_list_cache_state_db_fingerprint(state_db_path), + all_profiles_state_stamp, swv, ) diff --git a/tests/test_issue4842_cron_projection_perf.py b/tests/test_issue4842_cron_projection_perf.py index 8b15d1b9..8b46b9a1 100644 --- a/tests/test_issue4842_cron_projection_perf.py +++ b/tests/test_issue4842_cron_projection_perf.py @@ -199,7 +199,9 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path): session_dir.mkdir() with mock.patch("api.models.SESSION_DIR", session_dir): meta = models._state_projection_sidecar_metadata("cron_nope_999") - assert meta == {"title": None, "archived": False} + # No sidecar means no opinion on archived (None), so the projection falls back + # to the state.db row's archived flag (#7548) instead of forcing it to False. + assert meta == {"title": None, "archived": None} # No file → nothing cached (so the cache can't be poisoned by absent files). assert len(models._SIDECAR_METADATA_CACHE) == 0 diff --git a/tests/test_issue7548_cli_archived_sessions_projection.py b/tests/test_issue7548_cli_archived_sessions_projection.py new file mode 100644 index 00000000..0ad4b380 --- /dev/null +++ b/tests/test_issue7548_cli_archived_sessions_projection.py @@ -0,0 +1,97 @@ +"""Regression test for issue #7548: +CLI-archived cron/webhook/kanban sessions reappear from state.db projection (no sidecar case). +""" + +import sqlite3 +from pathlib import Path +from api.agent_sessions import read_importable_agent_session_rows +from api.models import get_cli_sessions, _state_projection_sidecar_metadata + + +def _create_state_db(path: Path, rows: list[tuple]): + conn = sqlite3.connect(str(path)) + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + message_count INTEGER, + started_at REAL, + source TEXT, + session_source TEXT, + archived INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + session_id TEXT, + role TEXT, + timestamp REAL + ) + """ + ) + for r in rows: + conn.execute( + "INSERT INTO sessions (id, title, model, message_count, started_at, source, session_source, archived) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + r, + ) + # insert a dummy user message so actual_message_count > 0 + conn.execute( + "INSERT INTO messages (session_id, role, timestamp) VALUES (?, 'user', ?)", + (r[0], r[4]), + ) + conn.commit() + conn.close() + + +def test_cli_archived_session_without_sidecar_retains_archived_state(tmp_path, monkeypatch): + """When a session is marked archived in state.db and has no WebUI sidecar, + the projection must preserve archived=True and not reset it to False.""" + db_path = tmp_path / "state.db" + session_rows = [ + ("cron-active-1", "Cron Active", "model", 1, 1000.0, "cron", "cron", 0), + ("cron-archived-1", "Cron Archived", "model", 1, 1000.0, "cron", "cron", 1), + ] + _create_state_db(db_path, session_rows) + + # Test read_importable_agent_session_rows directly + projected = read_importable_agent_session_rows( + db_path, + limit=200, + exclude_sources=None, + include_sources=("cron",), + ) + by_id = {r["id"]: r for r in projected} + assert "cron-active-1" in by_id + assert "cron-archived-1" in by_id + assert bool(by_id["cron-active-1"].get("archived")) is False + assert bool(by_id["cron-archived-1"].get("archived")) is True + + # Test _state_projection_sidecar_metadata returns archived: None when no sidecar exists + meta = _state_projection_sidecar_metadata("cron-archived-1") + assert meta.get("archived") is None + + # Test get_cli_sessions() projection + from api.models import clear_cli_sessions_cache + clear_cli_sessions_cache() + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + try: + import api.profiles # noqa: F401 # makes the monkeypatch target below importable + monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: str(tmp_path)) + except Exception: + pass + monkeypatch.setattr("api.models._active_state_db_path", lambda: db_path) + monkeypatch.setattr("api.models.SESSION_DIR", tmp_path / "webui_sessions") + (tmp_path / "webui_sessions").mkdir(parents=True, exist_ok=True) + + cli_sessions = get_cli_sessions(source_filter="cron") + cli_by_id = {s["session_id"]: s for s in cli_sessions} + assert "cron-active-1" in cli_by_id + assert "cron-archived-1" in cli_by_id + assert cli_by_id["cron-active-1"]["archived"] is False + assert cli_by_id["cron-archived-1"]["archived"] is True