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.
This commit is contained in:
Rodrigo Gomes da Silva committed 2026-09-24 11:03:46 -03:00
1 parent fdf7e5e429
commit 9d7374fa25
4 files changed
+375 -9

No files matched your search

+70 -3
View File
@@ -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},
+63 -5
View File
@@ -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(
+5 -1
View File
@@ -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