fix: let cron sessions bypass CLI_VISIBLE_SESSION_LIMIT for project chips (#3172)
When state.db has many non-cron sessions, the normal sidebar query caps at CLI_VISIBLE_SESSION_LIMIT (20) rows ordered by latest activity. Older cron runs get squeezed out before _include_project_hidden_background_sidebar_sessions can rescue them, making them invisible under their project chip. Add a second-pass cron-only query with a higher cap (CRON_PROJECT_CHIP_LIMIT=200) that merges into the CLI session list. The project-chip rescue layer then marks them default_hidden so they stay addressable without polluting the default sidebar window. Verification: regression test seeds 25+ newer non-cron sessions and asserts the older messageful cron session still appears with project_id set.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
165
tests/test_issue3172_cron_session_limit.py
Normal file
165
tests/test_issue3172_cron_session_limit.py
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user