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:
1 parent
fdf7e5e429
commit
9d7374fa25
4 files changed
+375
-9
No files matched your search
+70
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user