From 15da64f6ba6ba032e1a380649bc8a31d40d775f6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 18 Jun 2026 00:28:02 +0000 Subject: [PATCH] fix(#4385): keep archived cron sessions hidden after list refresh A stale sidecar with is_cli_session=true could make an archived cron/tool/API session resurface in the Sessions tab on refresh. The session loader now treats explicit cron/tool/api source metadata as non-CLI even when the sidecar claims is_cli_session, and preserves cron identity when the archive/materialization fallback creates a session. Co-authored-by: TomBanksAU <246584738+TomBanksAU@users.noreply.github.com> --- CHANGELOG.md | 6 + api/agent_sessions.py | 3 + api/routes.py | 6 +- .../test_issue4385_cron_archive_reappears.py | 135 ++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue4385_cron_archive_reappears.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc639f6a..d791d3fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.482] — 2026-06-17 — Release QR (archived cron sessions stay hidden) + +### Fixed + +- **Archived cron sessions no longer reappear in the Sessions tab after the list refreshes (#4385).** A stale sidecar with `is_cli_session: true` could make an archived cron/tool/API session resurface on refresh. The session loader now treats explicit cron/tool/API source metadata as non-CLI even when the sidecar claims otherwise, and preserves cron identity when the archive/materialization fallback creates a session, so archived cron rows stay hidden. Thanks @TomBanksAU. + ## [v0.51.481] — 2026-06-17 — Release QP (gateway start/stop/restart controls in Settings) ### Added diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 6a152e3f..d2e77554 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -173,6 +173,9 @@ def is_cli_session_row(row: dict) -> bool: source_label = _safe_lower(row.get("source_label")) if "webui" in {source, source_tag, raw_source, source_name, source_label}: return False + non_cli_sources = MESSAGING_SOURCES | {"cron", "tool", "api", "api_server"} + if {source, source_tag, raw_source, source_name, source_label} & non_cli_sources: + return False if source == "messaging": return False if source == "cli": diff --git a/api/routes.py b/api/routes.py index e94a2c76..2e7ab345 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2593,7 +2593,7 @@ def _get_or_materialize_session(sid: str): # Preserve source metadata fields def _apply_source_meta(s): - s.is_cli_session = True + s.is_cli_session = is_cli_session_row(cli_meta) s.source_tag = cli_meta.get("source_tag") s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") s.session_source = cli_meta.get("session_source") @@ -9936,7 +9936,7 @@ def handle_post(handler, parsed) -> bool: created_at=cli_meta.get("created_at"), updated_at=cli_meta.get("updated_at"), ) - s.is_cli_session = True + s.is_cli_session = is_cli_session_row(cli_meta) s.source_tag = cli_meta.get("source_tag") s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") s.session_source = cli_meta.get("session_source") @@ -9961,7 +9961,7 @@ def handle_post(handler, parsed) -> bool: created_at=cli_meta.get("created_at"), updated_at=cli_meta.get("updated_at"), ) - s.is_cli_session = True + s.is_cli_session = is_cli_session_row(cli_meta) s.source_tag = cli_meta.get("source_tag") s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") s.session_source = cli_meta.get("session_source") diff --git a/tests/test_issue4385_cron_archive_reappears.py b/tests/test_issue4385_cron_archive_reappears.py new file mode 100644 index 00000000..78ae4db3 --- /dev/null +++ b/tests/test_issue4385_cron_archive_reappears.py @@ -0,0 +1,135 @@ +"""Regression coverage for #4385: archived cron sessions reappearing.""" + +from __future__ import annotations + +from unittest.mock import patch + + +def test_cron_rows_are_not_cli_even_with_stale_cli_flag(): + """A stale sidecar flag must not turn a cron row into an external CLI row.""" + from api.agent_sessions import is_cli_session_row + + row = { + "session_id": "cron_job123_20260618", + "title": "Cron Session", + "source_tag": "cron", + "raw_source": "cron", + "session_source": "cron", + "source_label": "Cron", + "is_cli_session": True, + } + + assert is_cli_session_row(row) is False + + +def test_materializing_cron_session_preserves_non_cli_identity(monkeypatch): + """Cron materialization must not stamp the sidecar as CLI-imported.""" + import api.routes as routes + + sid = "cron_job123_20260618" + cron_meta = { + "session_id": sid, + "title": "Cron Session", + "model": "test-model", + "source_tag": "cron", + "raw_source": "cron", + "session_source": "cron", + "source_label": "Cron", + "read_only": False, + "profile": "default", + } + + class FakeSession: + def __init__(self): + self.session_id = sid + self.title = "Cron Session" + self.profile = "default" + self.model = "test-model" + self.archived = False + self.is_cli_session = False + self.source_tag = None + self.raw_source = None + self.session_source = None + self.source_label = None + self.read_only = False + + def save(self, *args, **kwargs): + pass + + def fake_import_cli_session(*args, **kwargs): + return FakeSession() + + with ( + patch.object(routes, "get_session", side_effect=KeyError(sid)), + patch.object(routes, "_lookup_cli_session_metadata", return_value=cron_meta), + patch.object( + routes, + "get_cli_session_messages", + return_value=[ + {"role": "user", "content": "run"}, + {"role": "assistant", "content": "done"}, + ], + ), + patch.object(routes, "import_cli_session", side_effect=fake_import_cli_session), + ): + session = routes._get_or_materialize_session(sid) + + assert session.session_source == "cron" + assert session.source_tag == "cron" + assert session.is_cli_session is False + + +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 + + sid = "cron_job123_20260618" + archived_sidecar = { + "session_id": sid, + "title": "Cron Session", + "profile": "default", + "updated_at": 20, + "last_message_at": 20, + "message_count": 1, + "user_message_count": 1, + "archived": True, + "source_tag": "cron", + "raw_source": "cron", + "session_source": "cron", + "source_label": "Cron", + "is_cli_session": True, + } + raw_cron_row = { + "session_id": sid, + "title": "Cron Session", + "profile": "default", + "updated_at": 20, + "last_message_at": 20, + "message_count": 1, + "user_message_count": 1, + "archived": False, + "project_id": "cron-project", + "source_tag": "cron", + "raw_source": "cron", + "session_source": "cron", + "source_label": "Cron", + "is_cli_session": False, + } + + monkeypatch.setattr(routes, "all_sessions", lambda diag=None: [archived_sidecar]) + monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [raw_cron_row]) + monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _sessions: False) + + payload = routes._build_session_list_cache_payload( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=True, + ) + + rows = payload["sessions"] + matching = [row for row in rows if row["session_id"] == sid] + assert len(matching) == 1 + assert matching[0]["archived"] is True + assert matching[0]["is_cli_session"] is False