fix(sidebar): prevent kanban runs from hiding CLI sessions (#6998)

* fix: prevent kanban rows from hiding CLI sessions

Keep worker-heavy Kanban history out of the bounded interactive session window and recover it through a separate capped pass. Add regression coverage and update query-pass contract tests.

* docs: describe imported session projection bounds

---------

Co-authored-by: zicochaos <23367003+zicochaos@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-08-14 10:39:36 +00:00
1 parent b58ea15e31
commit 9b8a9e05d1
7 files changed
+157 -16

No files matched your search

+18
View File
@@ -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:
+70 -1
View File
@@ -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
@@ -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
+7 -7
View File
@@ -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):
@@ -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"
)
@@ -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):
+5 -1
View File
@@ -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"