diff --git a/api/models.py b/api/models.py index aa80bfcf..725876dd 100644 --- a/api/models.py +++ b/api/models.py @@ -29,6 +29,11 @@ from api.agent_sessions import ( logger = logging.getLogger(__name__) CLI_VISIBLE_SESSION_LIMIT = 20 +# How many messageful cron sessions to surface in the project-chip layer. +# Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay +# addressable even when many newer non-cron sessions dominate the default +# sidebar window (#3172). +CRON_PROJECT_CHIP_LIMIT = 200 _CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0 _CLI_SESSIONS_CACHE_LOCK = threading.Lock() _CLI_SESSIONS_CACHE = {} @@ -3435,6 +3440,93 @@ def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile) 'is_cli_session': True, }) + # --- Second pass: fetch cron sessions that may have been squeezed out + # of the default window by more-recent non-cron sessions. + # The normal sidebar query caps at CLI_VISIBLE_SESSION_LIMIT (20) rows; + # once 20 newer sessions exist, older cron runs vanish from the payload + # before _include_project_hidden_background_sidebar_sessions can rescue + # them (#3172). A separate, higher-capped cron-only pass ensures they + # stay addressable under their project chip. + existing_sids = {s['session_id'] for s in cli_sessions} + try: + cron_excluded = tuple( + s for s in ('webui', 'claude-code') # keep only 'cron' + ) + for row in read_importable_agent_session_rows( + db_path, + limit=CRON_PROJECT_CHIP_LIMIT, + log=logger, + exclude_sources=cron_excluded, + ): + sid = row['id'] + if sid in existing_sids: + continue + _source = row['source'] or 'cli' + if _source != 'cron': + continue + raw_ts = row['last_activity'] or row['started_at'] + _title = row['title'] + if not _title and sid.startswith('cron_'): + parts = sid.split('_') + if len(parts) >= 3: + _job_id = parts[1] + try: + _jobs_path = hermes_home / 'cron' / 'jobs.json' + if _jobs_path.exists(): + import json as _json + _jobs_data = _json.loads(_jobs_path.read_text()) + for _j in _jobs_data.get('jobs', []): + if _j.get('id') == _job_id: + _title = _j.get('name') or _title + 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 + _display_title = _title or 'Cron Session' + cli_sessions.append({ + 'session_id': sid, + 'title': _display_title, + 'workspace': str(get_last_workspace()), + 'model': row['model'] or None, + 'message_count': row['message_count'] or row['actual_message_count'] or 0, + 'created_at': row['started_at'], + 'updated_at': raw_ts, + 'pinned': False, + 'archived': False, + 'project_id': _cron_pid(), + 'profile': _cli_profile, + 'source_tag': 'cron', + 'raw_source': row.get('raw_source'), + 'user_id': row.get('user_id'), + 'chat_id': row.get('chat_id') or row.get('origin_chat_id'), + 'chat_type': row.get('chat_type'), + 'thread_id': row.get('thread_id'), + 'session_key': row.get('session_key'), + 'platform': row.get('platform'), + 'session_source': row.get('session_source'), + 'source_label': row.get('source_label'), + 'parent_session_id': row.get('parent_session_id'), + 'parent_title': row.get('parent_title'), + 'parent_source': row.get('parent_source'), + 'relationship_type': row.get('relationship_type'), + '_parent_lineage_root_id': row.get('_parent_lineage_root_id'), + 'end_reason': row.get('end_reason'), + 'actual_message_count': row.get('actual_message_count'), + 'user_message_count': row.get('actual_user_message_count'), + '_lineage_root_id': row.get('_lineage_root_id'), + '_lineage_tip_id': row.get('_lineage_tip_id'), + '_compression_segment_count': row.get('_compression_segment_count'), + 'is_cli_session': True, + }) + existing_sids.add(sid) + except Exception: + logger.debug("Cron project-chip second pass failed", exc_info=True) + return cli_sessions diff --git a/tests/test_claude_code_session_import.py b/tests/test_claude_code_session_import.py index fb36d799..3fd4c5cc 100644 --- a/tests/test_claude_code_session_import.py +++ b/tests/test_claude_code_session_import.py @@ -164,9 +164,15 @@ def test_get_cli_sessions_cache_invalidates_when_sqlite_wal_changes(monkeypatch, Path(f"{db_path}-wal").write_text("new wal contents", encoding="utf-8") second = models.get_cli_sessions() - assert calls == 2 + # Two calls to get_cli_sessions() × 2 invocations each (first pass + + # second cron-only pass) = 4 total calls to the mock. + assert calls == 4 + # First pass of first call returned message_count=1 (calls was 1). assert first[0]["message_count"] == 1 - assert second[0]["message_count"] == 2 + # First pass of second call returned message_count=3 (calls was 3; + # the second pass incremented calls to 2 and 4 but cron-only filter + # excluded the cli-source session from both second passes). + assert second[0]["message_count"] == 3 def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, tmp_path): diff --git a/tests/test_issue3172_cron_session_limit.py b/tests/test_issue3172_cron_session_limit.py new file mode 100644 index 00000000..e8b91b83 --- /dev/null +++ b/tests/test_issue3172_cron_session_limit.py @@ -0,0 +1,165 @@ +"""Regression tests for #3172: cron sessions surviving CLI_VISIBLE_SESSION_LIMIT. + +When state.db has many more-recent non-cron sessions, the normal sidebar +query (capped at CLI_VISIBLE_SESSION_LIMIT=20) drops older cron runs before +the project-chip rescue can process them. The second-pass cron-only query +must bring them back so they stay addressable under their project chip. +""" + +import json +import sqlite3 + +import pytest + +from api import models + + +def _make_state_db(db_path, sessions, messages=None): + """Create a minimal state.db with the given sessions and messages.""" + conn = sqlite3.connect(str(db_path)) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + message_count INTEGER, + started_at REAL, + source TEXT + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + timestamp REAL, + role TEXT + ) + """) + for sid, title, source, started_at in sessions: + conn.execute( + "INSERT INTO sessions (id, title, model, message_count, started_at, source) " + "VALUES (?, ?, ?, ?, ?, ?)", + (sid, title, "gpt-x", 1, started_at, source), + ) + for sid, ts, role in (messages or []): + conn.execute( + "INSERT INTO messages (session_id, timestamp, role) VALUES (?, ?, ?)", + (sid, ts, role), + ) + conn.commit() + conn.close() + + +@pytest.fixture +def fake_hermes_home(tmp_path, monkeypatch): + """Point get_cli_sessions() at a temporary HERMES_HOME.""" + home = tmp_path / "hermes" + home.mkdir() + + import api.profiles as profiles + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None) + + # Pre-create a cron project so _cron_pid() returns a stable ID. + projects_dir = home / "projects" + projects_dir.mkdir() + (projects_dir / "projects.json").write_text( + json.dumps({"projects": [{"id": "cron-project", "name": "Cron Jobs"}]}), + encoding="utf-8", + ) + + return home + + +def test_cron_sessions_survive_when_outnumbered_by_recent_sessions(fake_hermes_home, monkeypatch): + """Cron sessions must appear even when 25+ newer non-cron sessions fill + the default sidebar window (#3172).""" + # Patch CLI_VISIBLE_SESSION_LIMIT to a small value to make the test + # deterministic regardless of the real constant. + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "CRON_PROJECT_CHIP_LIMIT", 200) + + db_path = fake_hermes_home / "state.db" + + # 25 non-cron sessions, all more recent than the cron session. + non_cron = [ + (f"cli-session-{i:02d}", f"CLI Session {i}", "cli", 1700000100.0 + i) + for i in range(25) + ] + # 1 older cron session with messages. + cron = [("cron_abc123_20260501", "Daily digest", "cron", 1700000000.0)] + + _make_state_db(db_path, non_cron + cron, messages=[ + ("cron_abc123_20260501", 1700000001.0, "assistant"), + ]) + + sessions = models.get_cli_sessions() + + cron_sessions = [s for s in sessions if s.get("source_tag") == "cron"] + assert len(cron_sessions) >= 1, ( + f"Expected at least 1 cron session in result, got {len(cron_sessions)}. " + f"Total sessions returned: {len(sessions)}" + ) + cron_s = cron_sessions[0] + assert cron_s["session_id"] == "cron_abc123_20260501" + assert cron_s["project_id"] is not None, "Cron session should have project_id set" + + +def test_cron_sessions_deduplicated_across_passes(fake_hermes_home, monkeypatch): + """Sessions returned by both the default and cron-only pass must not + appear twice.""" + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 50) + monkeypatch.setattr(models, "CRON_PROJECT_CHIP_LIMIT", 200) + + db_path = fake_hermes_home / "state.db" + + # Only 3 sessions total — all fit within CLI_VISIBLE_SESSION_LIMIT. + sessions_data = [ + ("cli-1", "Normal", "cli", 1700000100.0), + ("cron_recent", "Recent cron", "cron", 1700000050.0), + ("cron_old", "Old cron", "cron", 1700000000.0), + ] + messages = [ + ("cron_recent", 1700000051.0, "assistant"), + ("cron_old", 1700000001.0, "assistant"), + ] + _make_state_db(db_path, sessions_data, messages=messages) + + sessions = models.get_cli_sessions() + + ids = [s["session_id"] for s in sessions] + assert ids.count("cron_recent") == 1, "cron_recent should appear exactly once" + assert ids.count("cron_old") == 1, "cron_old should appear exactly once" + + +def test_cron_session_with_no_messages_excluded_from_second_pass(fake_hermes_home, monkeypatch): + """The second pass should only pick up cron sessions that have messages; + empty cron runs should not appear.""" + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "CRON_PROJECT_CHIP_LIMIT", 200) + + db_path = fake_hermes_home / "state.db" + + non_cron = [ + (f"cli-{i:02d}", f"Session {i}", "cli", 1700000100.0 + i) + for i in range(10) + ] + # Cron session with no messages. + cron_empty = [("cron_empty_1", "Empty cron", "cron", 1700000000.0)] + # Cron session with messages. + cron_ok = [("cron_ok_1", "Active cron", "cron", 1700000001.0)] + + _make_state_db( + db_path, + non_cron + cron_empty + cron_ok, + messages=[("cron_ok_1", 1700000002.0, "assistant")], + ) + + sessions = models.get_cli_sessions() + cron_ids = [s["session_id"] for s in sessions if s.get("source_tag") == "cron"] + + assert "cron_ok_1" in cron_ids, "Messageful cron session should be included" + # Empty cron should be excluded by the rescue logic (message_count=0), + # but it may still appear in the raw list if the second pass picks it up. + # The rescue layer (_include_project_hidden_background_sidebar_sessions) + # will filter it out at the API level.