From 9d7374fa250799110683e86267427e74dae47244 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 31 Jul 2026 18:12:49 -0300 Subject: [PATCH] fix(sessions): keep project-assigned CLI sessions past the recent-session cap Two independent 20-session caps hid older project-assigned CLI sessions: models.py only queried the 20 most recent, and routes.py capped the API payload again. Fixing the query alone was not enough. - agent_sessions.py: optional project_id filter; propagate the freshest project_id along a compression lineage (compression_tip now returns a 3-tuple) so a Project set only on the tip is not lost - models.py: second bounded query for older project-assigned sessions; skipped when the schema has no project_id; dedupe by id/lineage - routes.py: keep the 20 recent, drop unassigned overflow, preserve assigned overflow as default_hidden (revealed by the project chip) Adds regression tests for the limit, the root/tip lineage cases and the route cap. --- api/agent_sessions.py | 73 +++++- api/models.py | 68 ++++- api/routes.py | 6 +- ...test_project_assigned_cli_session_limit.py | 237 ++++++++++++++++++ 4 files changed, 375 insertions(+), 9 deletions(-) create mode 100755 tests/test_project_assigned_cli_session_limit.py diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 5b696222..85548b39 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -540,7 +540,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: for children in children_by_parent.values(): children.sort(key=lambda row: row.get('started_at') or 0, reverse=True) - def compression_tip(row: dict) -> tuple[dict | None, int]: + def compression_tip(row: dict) -> tuple[dict | None, int, str | None]: """Return the freshest importable continuation descendant for ``row``. Compression parents can have multiple continuation-looking children when @@ -552,6 +552,9 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None segment_count = 0 best_depth = 1 + latest_project_id: str | None = None + project_depth = 0 + project_score = -1.0 best_score = ( _as_score(latest_importable.get('last_activity'), latest_importable.get('started_at')) if latest_importable @@ -569,6 +572,17 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: segment_count += 1 current_score = _as_score(current.get('last_activity'), current.get('started_at')) + current_project_id = str(current.get('project_id') or '').strip() + if ( + current_project_id + and ( + current_score > project_score + or (current_score == project_score and depth >= project_depth) + ) + ): + latest_project_id = current_project_id + project_depth = depth + project_score = current_score if ( (current.get('actual_message_count') or 0) > 0 and (current_score > best_score or (current_score == best_score and depth >= best_depth)) @@ -584,7 +598,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: continue stack.append((child, depth + 1)) - return latest_importable, max(segment_count, 1) + return latest_importable, max(segment_count, 1), latest_project_id projected = [] for row in rows: @@ -593,8 +607,9 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: segment_count = 1 tip = row + lineage_project_id = str(row.get('project_id') or '').strip() or None if row.get('end_reason') in {'compression', 'cli_close'}: - tip, segment_count = compression_tip(row) + tip, segment_count, lineage_project_id = compression_tip(row) if not tip or (tip.get('actual_message_count') or 0) <= 0: continue @@ -617,6 +632,8 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]: ): if key in tip: merged[key] = tip[key] + if lineage_project_id: + merged['project_id'] = lineage_project_id if str(tip.get('source') or '').strip().lower() == 'tui': # TUI continuation rows are user-visible session segments (#6, #17, # ...), not opaque compression snapshots. Keep navigation pointed at @@ -649,6 +666,7 @@ def read_importable_agent_session_rows( log=None, exclude_sources: tuple[str, ...] | None = ("cron", "webui"), include_sources: tuple[str, ...] | None = None, + require_project_id: bool = False, ) -> list[dict]: """Return agent sessions projected as importable conversations. @@ -721,6 +739,7 @@ def read_importable_agent_session_rows( 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) + project_id_expr = _optional_col('project_id', 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 @@ -782,6 +801,53 @@ def read_importable_agent_session_rows( placeholders = ", ".join("?" for _ in excluded) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(excluded) + if require_project_id: + if 'project_id' not in session_cols: + return [] + if {'parent_session_id', 'end_reason'} <= session_cols: + continuation_checks = [ + "parent.end_reason IN ('compression', 'cli_close')", + "(parent.source IS NULL OR child.source IS NULL " + "OR LOWER(TRIM(parent.source)) = LOWER(TRIM(child.source)))", + ] + if 'ended_at' in session_cols: + continuation_checks.append( + "(parent.ended_at IS NULL OR child.started_at >= parent.ended_at)" + ) + if 'session_source' in session_cols: + continuation_checks.append( + "LOWER(TRIM(COALESCE(child.session_source, ''))) != 'fork'" + ) + continuation_where = " AND ".join(continuation_checks) + where_clauses.append( + f""" + s.id IN ( + WITH RECURSIVE project_lineage(id) AS ( + SELECT seed.id + FROM sessions seed + WHERE seed.project_id IS NOT NULL + AND TRIM(seed.project_id) != '' + UNION + SELECT child.id + FROM sessions child + JOIN project_lineage lineage ON child.parent_session_id = lineage.id + JOIN sessions parent ON parent.id = lineage.id + WHERE {continuation_where} + UNION + SELECT parent.id + FROM sessions child + JOIN project_lineage lineage ON child.id = lineage.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE {continuation_where} + ) + SELECT id FROM project_lineage + ) + """ + ) + else: + where_clauses.append( + "s.project_id IS NOT NULL AND TRIM(s.project_id) != ''" + ) # Without ``idx_messages_session`` the correlated ``MAX(mx.timestamp)`` # candidate ordering rescans ``messages`` once per session (seconds on @@ -828,6 +894,7 @@ def read_importable_agent_session_rows( {origin_chat_id_expr}, {origin_user_id_expr}, {platform_expr}, + {project_id_expr}, {parent_expr}, {ended_expr}, {end_reason_expr}, diff --git a/api/models.py b/api/models.py index 0515ef2a..cb5aeb12 100644 --- a/api/models.py +++ b/api/models.py @@ -53,6 +53,9 @@ logger = logging.getLogger(__name__) # nests when it wins a slot in this window. Resolved in api.config before # profile init; override with HERMES_WEBUI_VISIBLE_SESSION_LIMIT (clamped 1–200). CLI_VISIBLE_SESSION_LIMIT = _cfg.CLI_VISIBLE_SESSION_LIMIT +# Project chips must remain able to reveal older assigned CLI/TUI sessions even +# after newer unassigned rows fill the normal sidebar window. +PROJECT_ASSIGNED_CLI_LIMIT = 200 # 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 @@ -8216,6 +8219,16 @@ def _state_projection_sidecar_metadata(sid: str) -> dict: return dict(metadata) +def _state_db_supports_project_ids(db_path: Path) -> bool: + """Return whether the agent session schema can persist project assignments.""" + try: + with closing(open_state_db_readonly(db_path)) as conn: + rows = conn.execute("PRAGMA table_info(sessions)").fetchall() + except Exception: + return False + return any(str(row[1]) == "project_id" for row in rows) + + @profile_home_resolve_cache_scope() def _load_cli_sessions_uncached( hermes_home: Path, @@ -8224,6 +8237,7 @@ def _load_cli_sessions_uncached( source_filter=None, *, visible_session_limit: int | None = None, + project_assigned_limit: int | None | bool = PROJECT_ASSIGNED_CLI_LIMIT, cron_project_limit: int | None | bool = CRON_PROJECT_CHIP_LIMIT, webhook_project_limit: int | None | bool = WEBHOOK_PROJECT_CHIP_LIMIT, kanban_project_limit: int | None | bool = KANBAN_PROJECT_CHIP_LIMIT, @@ -8311,12 +8325,14 @@ def _load_cli_sessions_uncached( _webhook_pid_cache[0] = ensure_webhook_project(profile=_cli_profile) return _webhook_pid_cache[0] - def _state_row_project_id(sid: str, source: str | None) -> str | None: + def _state_row_project_id(row: dict) -> str | None: + sid = row['id'] + source = row.get('source') if is_cron_session(sid, source): return _cron_pid() if is_webhook_session(sid, source): return _webhook_pid() - return None + return row.get('project_id') profile_value = _cli_profile or 'default' # A deleted WebUI session is tombstoned (see _record_webui_deleted_session_tombstone) @@ -8329,7 +8345,7 @@ def _load_cli_sessions_uncached( _deleted_webui_tombstone = _load_webui_deleted_session_tombstone() except Exception: _deleted_webui_tombstone = frozenset() - for row in read_importable_agent_session_rows( + state_rows = read_importable_agent_session_rows( db_path, limit=visible_session_limit if visible_session_limit is not None else ( CRON_PROJECT_CHIP_LIMIT if source_filter == 'cron' @@ -8343,7 +8359,49 @@ def _load_cli_sessions_uncached( # (especially kanban) from evicting every CLI/TUI/ACP conversation. exclude_sources=("cron", "webhook", "kanban") if source_filter is None else None, include_sources=None if source_filter is None else (source_filter,), + ) + if ( + source_filter is None + and project_assigned_limit is not False + and _state_db_supports_project_ids(db_path) ): + represented_rows: dict[str, dict] = {} + for row in state_rows: + for key in ("id", "_lineage_root_id", "_lineage_tip_id"): + value = row.get(key) + if value: + represented_rows.setdefault(str(value), row) + try: + assigned_rows = read_importable_agent_session_rows( + db_path, + limit=project_assigned_limit, + log=logger, + exclude_sources=("cron", "webhook"), + require_project_id=True, + ) + for row in assigned_rows: + lineage_ids = [ + str(value) + for key in ("id", "_lineage_root_id", "_lineage_tip_id") + if (value := row.get(key)) + ] + existing = next( + (represented_rows[value] for value in lineage_ids if value in represented_rows), + None, + ) + if existing is not None: + existing.clear() + existing.update(row) + for value in lineage_ids: + represented_rows[value] = existing + continue + state_rows.append(row) + for value in lineage_ids: + represented_rows[value] = row + except Exception: + logger.debug("Project-assigned CLI second pass failed", exc_info=True) + + for row in state_rows: sid = row['id'] raw_ts = row['last_activity'] or row['started_at'] # Prefer the CLI session's own profile from the DB; fall back to @@ -8388,7 +8446,7 @@ def _load_cli_sessions_uncached( 'updated_at': raw_ts, 'pinned': False, 'archived': _archived, - 'project_id': _state_row_project_id(sid, _source), + 'project_id': _state_row_project_id(row), 'profile': profile, 'source_tag': _source, 'raw_source': row.get('raw_source') or _source_meta.get('raw_source'), @@ -8703,7 +8761,7 @@ def get_cli_sessions( ) ) return merged - load_kwargs = {'source_filter': source_filter} + load_kwargs: dict = {'source_filter': source_filter} if loader_supports_include_claude_code: load_kwargs['include_claude_code'] = include_claude_code return _load_cli_sessions_uncached( diff --git a/api/routes.py b/api/routes.py index a81660f8..6c0bfb0d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10500,7 +10500,11 @@ def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int | None = None) - if _is_cli_session_for_settings(session): cli_seen += 1 if cli_seen > cli_cap: - continue + project_id = str(session.get("project_id") or "").strip() + if not project_id: + continue + session = dict(session) + session["default_hidden"] = True kept.append(session) return kept diff --git a/tests/test_project_assigned_cli_session_limit.py b/tests/test_project_assigned_cli_session_limit.py new file mode 100755 index 00000000..99b72838 --- /dev/null +++ b/tests/test_project_assigned_cli_session_limit.py @@ -0,0 +1,237 @@ +"""Regression: project-assigned CLI sessions must survive the recent-session cap.""" + +import sqlite3 + +import pytest + +from api import models, routes + + +def _make_state_db(db_path, sessions): + conn = sqlite3.connect(str(db_path)) + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + message_count INTEGER, + started_at REAL, + source TEXT, + project_id TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + timestamp REAL, + role TEXT + ) + """ + ) + for sid, started_at, project_id in sessions: + conn.execute( + "INSERT INTO sessions " + "(id, title, model, message_count, started_at, source, project_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (sid, sid, "gpt-x", 1, started_at, "cli", project_id), + ) + conn.execute( + "INSERT INTO messages (session_id, timestamp, role) VALUES (?, ?, ?)", + (sid, started_at + 0.5, "user"), + ) + conn.commit() + conn.close() + + +@pytest.fixture +def fake_hermes_home(tmp_path, monkeypatch): + 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) + models.clear_cli_sessions_cache() + yield home + models.clear_cli_sessions_cache() + + +def test_project_assigned_cli_session_survives_recent_session_limit( + fake_hermes_home, monkeypatch +): + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + + newer_unassigned = [ + (f"recent-{index:02d}", 1700000100.0 + index, None) + for index in range(25) + ] + older_assigned = [("older-assigned", 1700000000.0, "project-123")] + _make_state_db(fake_hermes_home / "state.db", newer_unassigned + older_assigned) + + sessions = models.get_cli_sessions() + by_id = {session["session_id"]: session for session in sessions} + + assert "older-assigned" in by_id + assert by_id["older-assigned"]["project_id"] == "project-123" + assert [session["session_id"] for session in sessions].count("older-assigned") == 1 + + +@pytest.mark.parametrize( + ("root_project_id", "tip_project_id"), + [ + ("project-123", None), + (None, "project-123"), + ], +) +def test_project_assigned_compression_lineage_is_returned_once( + fake_hermes_home, + monkeypatch, + root_project_id, + tip_project_id, +): + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + db_path = fake_hermes_home / "state.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + message_count INTEGER, + started_at REAL, + source TEXT, + project_id TEXT, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + timestamp REAL, + role TEXT + ) + """ + ) + rows = [ + ( + "lineage-root", + 1700000000.0, + root_project_id, + None, + 1700000001.0, + "compression", + ), + ( + "lineage-tip", + 1700000200.0, + tip_project_id, + "lineage-root", + None, + None, + ), + ] + # Keep the tip inside the normal five-row window while pushing the root + # outside its 8x SQL candidate window. This reproduces the real two-pass + # boundary: the normal pass sees only the tip and the project pass must + # bring enough lineage context to avoid appending the root separately. + rows.extend( + ( + f"newer-{index}", + 1700000300.0 + index, + None, + None, + None, + None, + ) + for index in range(4) + ) + rows.extend( + ( + f"middle-{index}", + 1700000100.0 + index, + None, + None, + None, + None, + ) + for index in range(56) + ) + for sid, started_at, project_id, parent_id, ended_at, end_reason in rows: + conn.execute( + "INSERT INTO sessions " + "(id, title, model, message_count, started_at, source, project_id, " + "parent_session_id, ended_at, end_reason) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + sid, + sid, + "gpt-x", + 1, + started_at, + "cli", + project_id, + parent_id, + ended_at, + end_reason, + ), + ) + conn.execute( + "INSERT INTO messages (session_id, timestamp, role) VALUES (?, ?, ?)", + (sid, started_at + 0.5, "user"), + ) + conn.commit() + conn.close() + + sessions = models.get_cli_sessions() + lineage_rows = [ + session + for session in sessions + if session["session_id"] in {"lineage-root", "lineage-tip"} + ] + + assert len(lineage_rows) == 1 + assert lineage_rows[0]["session_id"] in {"lineage-root", "lineage-tip"} + assert lineage_rows[0]["project_id"] == "project-123" + + +def test_route_cap_preserves_project_assigned_overflow_as_hidden(): + recent = [ + { + "session_id": f"recent-{index}", + "is_cli_session": True, + "project_id": None, + } + for index in range(20) + ] + assigned_overflow = { + "session_id": "assigned-overflow", + "is_cli_session": True, + "project_id": "project-prime", + } + unassigned_overflow = { + "session_id": "unassigned-overflow", + "is_cli_session": True, + "project_id": None, + } + + kept = routes._cap_recent_cli_sessions( + recent + [assigned_overflow, unassigned_overflow], + cli_cap=20, + ) + + by_id = {row["session_id"]: row for row in kept} + assert len(kept) == 21 + assert "unassigned-overflow" not in by_id + assert by_id["assigned-overflow"]["default_hidden"] is True + assert "default_hidden" not in assigned_overflow