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:
7 files changed
+157
-16
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user