diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f657aad0..a8d98f92 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -235,6 +235,24 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy): title_from(): takes messages list, finds first user message, returns first 64 chars. Called after run_conversation() completes to set the session title retroactively. +#### Imported `state.db` sidebar projection + +`api.models.get_cli_sessions()` projects conversations from the active Hermes +profile's `state.db` into sidebar-shaped rows. The default projection keeps +interactive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions) +in a bounded 20-row candidate window. Background sources use independent recovery +passes so a high-volume worker source cannot consume that interactive window: + +- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows. +- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows. +- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows. + +Source-specific views still use their dedicated bounds, and the later sidebar +visibility stage decides whether recovered background rows are shown. In +`all_profiles=True` mode the per-profile source bounds are disabled before rows +are merged; cross-profile scoping, visibility, deduplication, and final route +limits remain downstream responsibilities. + ### 4.3 SSE Streaming Engine This is the most architecturally interesting part. Two endpoints cooperate: diff --git a/api/models.py b/api/models.py index 4bd18657..2d1e2edb 100644 --- a/api/models.py +++ b/api/models.py @@ -7459,6 +7459,7 @@ def _load_cli_sessions_uncached( visible_session_limit: int | None = None, 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, include_claude_code: bool = True, ) -> list: cli_sessions = [] @@ -7565,7 +7566,10 @@ def _load_cli_sessions_uncached( else CLI_VISIBLE_SESSION_LIMIT ), log=logger, - exclude_sources=("cron", "webhook") if source_filter is None else None, + # Background sources have independent bounded passes below. Keeping them + # out of this 20-row interactive window prevents a busy worker source + # (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,), ): sid = row['id'] @@ -7776,6 +7780,70 @@ def _load_cli_sessions_uncached( except Exception: logger.debug("Webhook project-chip second pass failed", exc_info=True) + # --- Second pass: fetch kanban sessions without letting a kanban-heavy + # state.db consume the interactive CLI_VISIBLE_SESSION_LIMIT window. + if kanban_project_limit is not False: + existing_sids = {s['session_id'] for s in cli_sessions} + try: + for row in read_importable_agent_session_rows( + db_path, + limit=kanban_project_limit, + log=logger, + exclude_sources=None, + include_sources=("kanban",), + ): + sid = row['id'] + if sid in existing_sids: + continue + _source = row['source'] or 'kanban' + if _source != 'kanban': + continue + _source_meta = normalize_agent_session_source(_source) + raw_ts = row['last_activity'] or row['started_at'] + _title = row['title'] + _sidecar_meta = _state_projection_sidecar_metadata(sid) + if _sidecar_meta.get('title'): + _title = _sidecar_meta['title'] + _archived = bool(_sidecar_meta.get('archived')) + cli_sessions.append({ + 'session_id': sid, + 'title': _title or 'Kanban Session', + 'workspace': _cli_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': _archived, + 'project_id': _state_row_project_id(sid, _source), + 'profile': profile_value, + 'source_tag': 'kanban', + 'raw_source': row.get('raw_source') or _source_meta.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') or _source_meta.get('session_source'), + 'source_label': row.get('source_label') or _source_meta.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': is_cli_session_row({**row, **_source_meta}), + }) + existing_sids.add(sid) + except Exception: + logger.debug("Kanban sidebar second pass failed", exc_info=True) + return cli_sessions @@ -7838,6 +7906,7 @@ def get_cli_sessions( 'visible_session_limit': None, 'cron_project_limit': None, 'webhook_project_limit': None, + 'kanban_project_limit': None, } if loader_supports_include_claude_code: load_kwargs['include_claude_code'] = include_claude_code and idx == 0 diff --git a/tests/test_acp_session_sidebar_visibility.py b/tests/test_acp_session_sidebar_visibility.py index 64c86763..d4e3e233 100644 --- a/tests/test_acp_session_sidebar_visibility.py +++ b/tests/test_acp_session_sidebar_visibility.py @@ -175,3 +175,49 @@ def test_acp_session_appears_in_projection_as_cli(tmp_path): assert row['is_cli_session'] is True assert row['session_source'] == 'cli' assert row['source_label'] == 'ACP' + + +def test_newer_kanban_rows_do_not_evict_cli_sessions_from_projection(tmp_path): + """Kanban has a bounded window separate from interactive conversations.""" + kanban_count = models.KANBAN_PROJECT_CHIP_LIMIT + 5 + sessions = [ + { + 'id': 'older-cli-session', + 'source': 'cli', + 'title': 'CLI conversation', + }, + *[ + { + 'id': f'newer-kanban-{i:03d}', + 'source': 'kanban', + 'title': f'Kanban worker {i:03d}', + } + for i in range(kanban_count) + ], + ] + + results = _call_uncached(tmp_path, sessions) + result_ids = {row['session_id'] for row in results} + kanban_rows = [row for row in results if row.get('source_tag') == 'kanban'] + + assert 'older-cli-session' in result_ids + assert len(kanban_rows) == models.KANBAN_PROJECT_CHIP_LIMIT + assert {row['session_id'] for row in kanban_rows} == { + f'newer-kanban-{i:03d}' + for i in range(kanban_count - models.KANBAN_PROJECT_CHIP_LIMIT, kanban_count) + } + + from api.routes import _dedupe_cli_sidebar_sessions_for_api + + hidden = _dedupe_cli_sidebar_sessions_for_api( + results, + set(), + show_kanban_sessions=False, + ) + visible = _dedupe_cli_sidebar_sessions_for_api( + results, + set(), + show_kanban_sessions=True, + ) + assert not any(row.get('source_tag') == 'kanban' for row in hidden) + assert sum(row.get('source_tag') == 'kanban' for row in visible) == models.KANBAN_PROJECT_CHIP_LIMIT diff --git a/tests/test_claude_code_session_import.py b/tests/test_claude_code_session_import.py index 2222ee26..9d9af034 100644 --- a/tests/test_claude_code_session_import.py +++ b/tests/test_claude_code_session_import.py @@ -164,15 +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() - # Two calls to get_cli_sessions() × 3 invocations each (first pass + - # cron-only pass + webhook-only pass) = 6 total calls to the mock. - assert calls == 6 + # Two calls to get_cli_sessions() × 4 invocations each (first pass + + # cron-only + webhook-only + kanban-only passes) = 8 total mock calls. + assert calls == 8 # First pass of first call returned message_count=1 (calls was 1). assert first[0]["message_count"] == 1 - # First pass of second call returned message_count=4 (calls was 4; - # source-specific passes incremented calls to 2, 3, 5, and 6 but excluded - # the cli-source session from those pass results). - assert second[0]["message_count"] == 4 + # First pass of second call returned message_count=5 (calls was 5; + # source-specific passes incremented the other counters but excluded the + # cli-source session from those pass results). + assert second[0]["message_count"] == 5 def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, tmp_path): diff --git a/tests/test_issue2841_show_cron_sessions_toggle.py b/tests/test_issue2841_show_cron_sessions_toggle.py index 49fa4b26..be1fabf2 100644 --- a/tests/test_issue2841_show_cron_sessions_toggle.py +++ b/tests/test_issue2841_show_cron_sessions_toggle.py @@ -250,16 +250,18 @@ def test_kanban_source_filter_overrides_default_hide(): def test_kanban_filtered_view_capped_at_chip_limit(): """A filtered kanban-only view (source_filter=='kanban') is bounded by a - dedicated KANBAN_PROJECT_CHIP_LIMIT, mirroring cron/webhook. (Kanban is NOT - added to the general exclude_sources — the original PR intentionally lets - kanban load on the source_filter=None path so the toggle-on case works; the - hide is applied by _hide_from_default_sidebar only when the toggle is off.)""" + dedicated KANBAN_PROJECT_CHIP_LIMIT, mirroring cron/webhook. The default + interactive query excludes kanban so worker-heavy databases cannot evict + CLI rows; a separate kanban-only pass preserves toggle-on behavior.""" src = _read("api/models.py") assert "KANBAN_PROJECT_CHIP_LIMIT if source_filter == 'kanban'" in src, ( "a bounded kanban-only project-chip limit must exist for the filtered view" ) - assert '("cron", "webhook") if source_filter is None' in src, ( - "kanban must NOT be excluded from the general query (would break toggle-on)" + assert '("cron", "webhook", "kanban") if source_filter is None' in src, ( + "kanban must not consume the bounded interactive-session query" + ) + assert 'include_sources=("kanban",)' in src, ( + "a separate bounded kanban pass must preserve toggle-on behavior" ) diff --git a/tests/test_issue3585_cron_session_overflow.py b/tests/test_issue3585_cron_session_overflow.py index 41fe8ae3..c9156e8d 100644 --- a/tests/test_issue3585_cron_session_overflow.py +++ b/tests/test_issue3585_cron_session_overflow.py @@ -136,11 +136,13 @@ def test_cron_sessions_recovered_by_second_pass(tmp_path): cron_sessions = [s for s in result if s["source_tag"] == "cron"] assert len(cron_sessions) > 0, "Cron sessions should be recovered by the second pass" - assert len(read_rows.call_args_list) == 3 + assert len(read_rows.call_args_list) == 4 assert read_rows.call_args_list[1].kwargs["exclude_sources"] is None assert read_rows.call_args_list[1].kwargs["include_sources"] == ("cron",) assert read_rows.call_args_list[2].kwargs["exclude_sources"] is None assert read_rows.call_args_list[2].kwargs["include_sources"] == ("webhook",) + assert read_rows.call_args_list[3].kwargs["exclude_sources"] is None + assert read_rows.call_args_list[3].kwargs["include_sources"] == ("kanban",) def test_webui_sidecarless_sessions_not_excluded(tmp_path): diff --git a/tests/test_webhook_project_sessions.py b/tests/test_webhook_project_sessions.py index 4c613764..fc60b6e3 100644 --- a/tests/test_webhook_project_sessions.py +++ b/tests/test_webhook_project_sessions.py @@ -144,7 +144,11 @@ def test_webhook_second_pass_keeps_older_project_rows_available(monkeypatch, tmp rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False) - assert [call.get("include_sources") for call in calls] == [None, ("webhook",)] + assert [call.get("include_sources") for call in calls] == [ + None, + ("webhook",), + ("kanban",), + ] assert len(rows) == 1 assert rows[0]["session_id"] == "webhook-older" assert rows[0]["project_id"] == "webhook-project-id"