From 5c62f849fa79e274ed2b0ab8bd4a61c90ebaf672 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 18 Jun 2026 03:33:44 +0000 Subject: [PATCH] fix(#4397): keep archived cron runs hidden on state.db re-projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #4385/#4388: that fix stopped stale cron sidecars from being treated as CLI rows, but get_cli_sessions() still re-projected the same cron run from Hermes state.db with a hardcoded archived: false after all_sessions() omitted the hidden sidecar — so an archived run reappeared on refresh. - New _state_projection_sidecar_metadata(sid) helper returns UI-owned title + archived flag from the WebUI sidecar (load_metadata_only), degrading gracefully to {title:None, archived:False} on any miss. - Both state.db-projection paths (standard CLI + cron fallback) now use it and carry the archived flag onto the projected row (was hardcoded False), so an archived cron/tool/API run stays hidden regardless of which path surfaces it. Title-preference behavior (#1486 rename persistence) is preserved. Co-authored-by: TomBanksAU <246584738+TomBanksAU@users.noreply.github.com> --- CHANGELOG.md | 6 +++ api/models.py | 47 ++++++++++------ .../test_issue4385_cron_archive_reappears.py | 54 +++++++++++++++++++ 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d132f60..8820dfcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.486] — 2026-06-18 — Release QV (archived cron runs stay hidden on state.db re-projection) + +### Fixed + +- **Archived cron runs no longer reappear in the Sessions panel after a refresh (#4397).** A follow-up to #4385/#4388: that fix stopped stale cron *sidecars* from being treated as CLI rows, but a remaining path let `get_cli_sessions()` re-project the same cron run straight from Hermes `state.db` with `archived: false` (after `all_sessions()` omitted the hidden sidecar). The state.db projection now reads the UI-owned sidecar metadata (title **and** archived flag) via a shared helper and carries the archived flag onto the projected row, so an archived cron/tool/API run stays hidden no matter which path surfaces it. Thanks @TomBanksAU. + ## [v0.51.485] — 2026-06-18 — Release QU (mobile: bigger hamburger tap target + browser edge-swipe) ### Improved diff --git a/api/models.py b/api/models.py index 7a928526..86c35482 100644 --- a/api/models.py +++ b/api/models.py @@ -3877,6 +3877,22 @@ def _all_profiles_cli_contexts() -> tuple[list[tuple[Path, Path, str | None]], t return contexts, tuple(cache_entries) +def _state_projection_sidecar_metadata(sid: str) -> dict: + """Return UI-owned metadata for a state.db-projected sidebar row.""" + metadata = {"title": None, "archived": False} + try: + webui_meta = Session.load_metadata_only(sid) + except Exception: + return metadata + if not webui_meta: + return metadata + title = getattr(webui_meta, 'title', None) + if title: + metadata["title"] = title + metadata["archived"] = bool(getattr(webui_meta, 'archived', False)) + return metadata + + def _load_cli_sessions_uncached( hermes_home: Path, db_path: Path, @@ -3944,15 +3960,14 @@ def _load_cli_sessions_uncached( except Exception: pass # degrade gracefully # If a WebUI JSON file exists for this session (e.g. previously - # imported or renamed in the sidebar), prefer its title over the - # state.db title. This fixes rename-not-persisting for CLI sessions - # after compression chain extension (#1486). - try: - _webui_meta = Session.load_metadata_only(sid) - if _webui_meta and getattr(_webui_meta, 'title', None): - _title = _webui_meta.title - except Exception: - pass + # imported or renamed in the sidebar), prefer its UI-owned metadata over + # the state.db projection. This keeps archived cron/tool/API runs hidden + # even when all_sessions() omits the hidden sidecar and the state row is + # re-injected from Hermes state.db (#4397). + _sidecar_meta = _state_projection_sidecar_metadata(sid) + if _sidecar_meta.get('title'): + _title = _sidecar_meta['title'] + _archived = bool(_sidecar_meta.get('archived')) _display_title = _title or f'{_source.title()} Session' cli_sessions.append({ 'session_id': sid, @@ -3963,7 +3978,7 @@ def _load_cli_sessions_uncached( 'created_at': row['started_at'], 'updated_at': raw_ts, 'pinned': False, - 'archived': False, + 'archived': _archived, 'project_id': _cron_pid() if is_cron_session(sid, _source) else None, 'profile': profile, 'source_tag': _source, @@ -4033,12 +4048,10 @@ def _load_cli_sessions_uncached( break except Exception: pass - try: - _webui_meta = Session.load_metadata_only(sid) - if _webui_meta and getattr(_webui_meta, 'title', None): - _title = _webui_meta.title - except Exception: - pass + _sidecar_meta = _state_projection_sidecar_metadata(sid) + if _sidecar_meta.get('title'): + _title = _sidecar_meta['title'] + _archived = bool(_sidecar_meta.get('archived')) _display_title = _title or 'Cron Session' cli_sessions.append({ 'session_id': sid, @@ -4049,7 +4062,7 @@ def _load_cli_sessions_uncached( 'created_at': row['started_at'], 'updated_at': raw_ts, 'pinned': False, - 'archived': False, + 'archived': _archived, 'project_id': _cron_pid(), 'profile': profile_value, 'source_tag': 'cron', diff --git a/tests/test_issue4385_cron_archive_reappears.py b/tests/test_issue4385_cron_archive_reappears.py index 78ae4db3..7db0524c 100644 --- a/tests/test_issue4385_cron_archive_reappears.py +++ b/tests/test_issue4385_cron_archive_reappears.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 from unittest.mock import patch @@ -79,6 +80,59 @@ def test_materializing_cron_session_preserves_non_cli_identity(monkeypatch): assert session.is_cli_session is False +def test_cron_state_projection_preserves_archived_sidecar(monkeypatch, tmp_path): + """A hidden archived sidecar must still mark the state.db cron projection archived.""" + import api.models as models + + sid = "cron_job123_20260618" + db_path = tmp_path / "state.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + message_count INTEGER, + started_at REAL, + source TEXT, + session_source TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO sessions ( + id, title, model, message_count, started_at, source, session_source + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (sid, "Cron Session", "test-model", 1, 20, "cron", "cron"), + ) + + class ArchivedSidecar: + title = "Cron Session" + archived = True + + monkeypatch.setattr( + models.Session, + "load_metadata_only", + staticmethod(lambda candidate: ArchivedSidecar() if candidate == sid else None), + ) + monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project") + + rows = models._load_cli_sessions_uncached( + tmp_path, + db_path, + "default", + source_filter="cron", + include_claude_code=False, + ) + + assert len(rows) == 1 + assert rows[0]["session_id"] == sid + assert rows[0]["archived"] is True + + def test_archived_cron_sidecar_suppresses_raw_unarchived_cron_row(monkeypatch): """An archived cron sidecar should keep the raw state.db cron row hidden.""" import api.routes as routes