diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aaffb2b..ee24ca4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,16 @@ ### Fixed +- **CLI sessions you moved into a project stay in that project.** CLI sessions were cut to the + recent-session limit before project assignment was checked, so a CLI conversation you'd moved + into a project vanished from its project chip once enough newer sessions existed. The + assignment was still saved; only the row was gone. The recent limit now applies to unassigned + conversations only. Assigned ones stay in the payload (hidden from the main list once the recent + window is full) so the project chips can show them. Assigned rows have their own bound + (`CLI_PROJECT_ASSIGNED_CAP`, 200 across all projects, shared fairly so one busy project can't + take every slot), and a compressed CLI conversation keeps its project through its whole + continuation chain. Thanks @rodrigogs. (#6659) + - **Two open WebUI windows no longer overwrite each other's unread state.** Two clients on the same origin (for example the desktop PWA and a browser tab) share one `localStorage`, but each kept its own in-memory copy of which sessions had been viewed. When one wrote, it replaced the diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 5b696222..8c0a776a 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -113,6 +113,16 @@ CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2 # window cannot collapse genuinely concurrently started children. CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0 +# Accepted ``project_assignment`` values for read_importable_agent_session_rows. +PROJECT_ASSIGNMENT_FILTERS = frozenset({'assigned', 'unassigned'}) + +# Raw-row oversample factors for the bounded candidate window. ``limit`` counts +# LOGICAL conversations, but compression segments and the post-projection +# visibility filters are spent from the raw window first, so a single 8x pass can +# come up short on a lineage-heavy profile. The second factor is only paid when +# the first window was fully consumed AND still under-delivered. +CANDIDATE_WINDOW_MULTIPLIERS = (8, 32) + SOURCE_LABELS = { 'acp': 'ACP', 'api_server': 'API', @@ -540,7 +550,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 +562,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 +582,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 +608,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,13 +617,21 @@ 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 if tip is row: - projected.append(dict(row)) + # The root can still be the freshest *importable* segment while a + # newer EMPTY continuation carries the project assignment. Applying + # the resolved lineage id here too keeps that assignment instead of + # silently dropping it on the early-return path (#6659). + root_only = dict(row) + if lineage_project_id: + root_only['project_id'] = lineage_project_id + projected.append(root_only) continue merged = dict(row) @@ -617,6 +649,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,7 +683,10 @@ def read_importable_agent_session_rows( log=None, exclude_sources: tuple[str, ...] | None = ("cron", "webui"), include_sources: tuple[str, ...] | None = None, -) -> list[dict]: + project_assignment: str | None = None, + project_ids: tuple[str, ...] | None = None, + return_window_exhaustion: bool = False, +) -> list[dict] | tuple[list[dict], bool]: """Return agent sessions projected as importable conversations. Hermes Agent can create rows in ``state.db.sessions`` before a session has @@ -666,22 +703,67 @@ def read_importable_agent_session_rows( filter; callers that want an include-only query should explicitly pass ``exclude_sources=None`` so the default exclusions do not also apply. + ``project_assignment`` narrows to project-assigned (``'assigned'``) or + project-free (``'unassigned'``) conversations; ``None`` returns both. The + two are exact complements and key on the whole compression lineage, so a + caller can budget assigned and unassigned conversations independently + without either filter double-counting a lineage (#6659). + + ``project_ids`` narrows the assigned set further, to conversations whose + LINEAGE is assigned to one of the given projects. It exists so a caller can + give each project its own ``limit`` instead of sharing one global window: a + single ``project_assignment='assigned'`` query orders by recency, so one busy + project can consume the whole window and leave quieter projects unreachable + (greptile P1 on #6659). Only meaningful together with + ``project_assignment='assigned'``; any other combination raises ValueError + rather than silently returning an unnarrowed (and therefore starvable) set. + + ``limit`` bounds LOGICAL conversations, not raw rows: the slice is applied + after lineage projection, and the raw candidate window is re-widened once + if compression segments consumed it before the projection could fill the + request. + ``limit`` bounds the *recency slice*, not the returned row count. Subagent rows only render as children when their parent row is in the same payload, so subagent ancestors of selected rows are re-added afterwards and the result can exceed ``limit`` by the number of such anchors. Callers must therefore iterate the result rather than assume ``len(rows) <= limit``. + ``return_window_exhaustion=True`` returns ``(rows, exhausted)`` instead of + just ``rows``. ``exhausted`` is true only when the final bounded raw-candidate + window was full while its logical projection still under-delivered ``limit``. + That lets a caller distinguish a genuinely short database from a compressed + or filtered projection that needs a narrow recovery query, without paying a + count probe for ordinary short windows. + That recovery is deliberately bounded by the oversampled candidate set - (``limit * 8`` newest sessions): it re-uses rows the projection already - fetched and never issues an extra query, so an ancestor older than the - oversample stays unresolved and its children render top-level, exactly as - they did before. Widening that window is a ``candidate_limit`` change, not - a change to this walk. + (the ``CANDIDATE_WINDOW_MULTIPLIERS`` window that actually ran — ``limit * + 8``, or ``limit * 32`` when the first window was consumed and re-widened): + it re-uses rows the projection already fetched and never issues an extra + query, so an ancestor older than the oversample stays unresolved and its + children render top-level, exactly as they did before. Widening that window + is a ``CANDIDATE_WINDOW_MULTIPLIERS`` change, not a change to this walk. """ + def _result(rows: list[dict], exhausted: bool = False): + """Preserve the list API unless this caller needs raw-window status.""" + return (rows, exhausted) if return_window_exhaustion else rows + + wanted_project_ids: tuple[str, ...] = () + if project_ids is not None: + if str(project_assignment or '').strip().lower() != 'assigned': + raise ValueError( + "project_ids requires project_assignment='assigned'" + ) + wanted_project_ids = tuple( + cleaned for value in project_ids if (cleaned := str(value or '').strip()) + ) + if not wanted_project_ids: + # An empty narrowing selects nothing. Returning [] keeps that exact + # instead of degrading to "every assigned conversation". + return _result([]) db_path = Path(db_path) if not db_path.exists(): - return [] + return _result([]) log = log or logger conn = open_state_db_readonly(db_path, log=log) @@ -705,7 +787,7 @@ def read_importable_agent_session_rows( "Upgrade hermes-agent to fix this.", db_path, ) - return [] + return _result([]) parent_expr = _optional_col('parent_session_id', session_cols) session_source_expr = _optional_col('session_source', session_cols) @@ -721,6 +803,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 +865,90 @@ def read_importable_agent_session_rows( placeholders = ", ".join("?" for _ in excluded) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(excluded) + if project_assignment is not None: + wanted = str(project_assignment).strip().lower() + if wanted not in PROJECT_ASSIGNMENT_FILTERS: + raise ValueError( + "project_assignment must be one of " + f"{sorted(PROJECT_ASSIGNMENT_FILTERS)} or None" + ) + if 'project_id' not in session_cols: + # A schema that cannot persist assignments has no assigned rows, + # and every row it does have is unassigned. Keep both answers + # exact instead of emitting SQL against a missing column. + if wanted == 'assigned': + return _result([]) + elif {'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) + # An assignment anywhere in a compression lineage assigns the + # whole logical conversation, so both filters must key on the + # lineage, not the individual row: 'unassigned' is the exact + # complement of 'assigned' (#6659). + membership = "IN" if wanted == 'assigned' else "NOT IN" + # ``project_ids`` narrows only the SEED: the lineage walk still + # pulls in the whole compression chain around a seeded row, so a + # per-project query returns the same logical conversations the + # global one would have, minus the other projects'. + seed_project_filter = "" + if wanted_project_ids: + placeholders = ", ".join("?" for _ in wanted_project_ids) + seed_project_filter = ( + f"\n AND TRIM(seed.project_id) IN ({placeholders})" + ) + where_clauses.append( + f""" + s.id {membership} ( + WITH RECURSIVE project_lineage(id) AS ( + SELECT seed.id + FROM sessions seed + WHERE seed.project_id IS NOT NULL + AND TRIM(seed.project_id) != ''{seed_project_filter} + 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 + ) + """ + ) + params.extend(wanted_project_ids) + elif wanted == 'assigned': + if wanted_project_ids: + placeholders = ", ".join("?" for _ in wanted_project_ids) + where_clauses.append( + f"TRIM(COALESCE(s.project_id, '')) IN ({placeholders})" + ) + params.extend(wanted_project_ids) + else: + where_clauses.append( + "s.project_id IS NOT NULL AND TRIM(s.project_id) != ''" + ) + else: + where_clauses.append( + "(s.project_id IS NULL OR TRIM(s.project_id) = '')" + ) # Without ``idx_messages_session`` the correlated ``MAX(mx.timestamp)`` # candidate ordering rescans ``messages`` once per session (seconds on @@ -828,6 +995,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}, @@ -836,10 +1004,21 @@ def read_importable_agent_session_rows( {user_message_count_expr} AS actual_user_message_count, {last_activity_expr} AS last_activity """ + + def _project(raw_rows: list[dict]) -> list[dict]: + projected = _project_agent_session_rows(raw_rows) + # model_config is selected only so the continuation classifier can + # read the real _branched_from/_delegate_from markers; it is internal + # agent state and must not leak into the /api/sessions response. + for row in projected: + row.pop('model_config', None) + projected = [_with_normalized_source(row) for row in projected] + return [row for row in projected if is_cli_session_row_visible(row)] + if limit is not None: result_limit = max(0, int(limit)) if result_limit == 0: - return [] + return _result([]) # The sidebar only needs a small visible window. Bound the expensive # messages join to a recent-activity candidate set instead of # aggregating every historical Hermes state.db session before @@ -848,7 +1027,6 @@ def read_importable_agent_session_rows( # can be resumed days later and should still surface at the top. # Oversampling preserves room for hidden compression segments or # other rows filtered after projection. - candidate_limit = max(result_limit * 8, result_limit) if latest_messages_cte: candidate_cte = ( "WITH {latest_messages_cte}, candidates AS (\n" @@ -878,8 +1056,7 @@ def read_importable_agent_session_rows( candidate_order_clause=candidate_order_clause, ) - cur.execute( - f""" + candidate_sql = f""" {candidate_cte} {select_sql} FROM sessions s @@ -887,69 +1064,142 @@ def read_importable_agent_session_rows( {join_clause} {group_by_clause} {order_by_clause} - """, - [*params, candidate_limit], + """ + # Compression segments and the post-projection visibility filters are + # spent from the RAW candidate window before the logical slice below, + # so a lineage-heavy profile can return far fewer conversations than + # asked for (greptile P1 on #6659). Widen the window once when it was + # fully consumed and still came up short instead of silently + # truncating the caller's request. + projected: list[dict] = [] + window_exhausted = False + for multiplier in CANDIDATE_WINDOW_MULTIPLIERS: + candidate_limit = max(result_limit * multiplier, result_limit) + cur.execute(candidate_sql, [*params, candidate_limit]) + raw_rows = [dict(row) for row in cur.fetchall()] + projected = _project(raw_rows) + window_exhausted = ( + len(raw_rows) >= candidate_limit + and len(projected) < result_limit + ) + if not window_exhausted: + # Either the request is satisfied or the raw candidate window + # was not binding, so projection has seen every candidate this + # query can reveal. + break + selected = projected[:result_limit] + + # The recency slice is per-row, but subagent rows are only renderable as + # children: the sidebar nests a child under its parent solely when that + # parent row is present in the same payload. A frozen orchestrator stops + # writing while its leaves keep streaming, so the leaves win the recency + # race and the parent falls outside the window — leaving the leaves to be + # promoted to top-level sidebar rows. Re-add subagent parents that the + # oversampled candidate set already projected (no extra query); webui + # ancestors are left out because that sidebar bucket already has them. + # + # Bounded by construction: ``by_id`` only holds the candidates of the + # window that was actually executed (``limit * 8``, or ``limit * 32`` + # when the re-widening pass above had to run), so an ancestor older + # than that oversample is not recovered and its children stay + # top-level — the pre-existing behaviour, narrowed rather than fixed. + # Resolving those would need an unbounded per-row ancestor query on + # the hot sidebar path; widen ``CANDIDATE_WINDOW_MULTIPLIERS`` instead + # if the window proves too tight. + # NOTE: this can return more than ``limit`` rows (see docstring). + have = {row.get('id') for row in selected} + by_id = {row.get('id'): row for row in projected if row.get('id')} + pending = list(selected) + while pending: + row = pending.pop() + if str(row.get('raw_source') or row.get('source') or '').strip().lower() != 'subagent': + continue + parent_id = row.get('parent_session_id') + if not parent_id or parent_id in have: + continue + parent = by_id.get(parent_id) + if parent is None: + continue + if str(parent.get('raw_source') or parent.get('source') or '').strip().lower() != 'subagent': + continue + selected.append(parent) + have.add(parent_id) + pending.append(parent) + return _result(selected, window_exhausted) + + cur.execute( + f""" + {select_sql} + FROM sessions s + {join_clause} + WHERE {' AND '.join(where_clauses)} + {group_by_clause} + {order_by_clause} + """, + params, + ) + return _result(_project([dict(row) for row in cur.fetchall()])) + + +def read_assigned_project_row_counts( + db_path: Path, + log=None, + exclude_sources: tuple[str, ...] | None = None, +) -> dict[str, int]: + """Return ``{project_id: assigned raw row count}`` from ``state.db``. + + A single GROUP BY over ``sessions.project_id``: no messages join, no lineage + recursion, and an answer bounded by the number of distinct project ids. It + exists so the assigned-recovery pass can tell WHICH projects still have rows + it has not delivered, and pay a per-project follow-up query only for those, + instead of one query per registered project (greptile P1 on #6659). + + The counts are RAW rows, so a compression lineage counts once per segment. + That makes the signal deliberately conservative — it can overestimate what a + project still owes, never underestimate it, so a starved project is always + detected while at worst one follow-up query comes back empty. + """ + db_path = Path(db_path) + log = log or logger + if not db_path.exists(): + return {} + try: + with closing(open_state_db_readonly(db_path, log)) as conn: + cur = conn.cursor() + cur.execute("PRAGMA table_info(sessions)") + session_cols = {row[1] for row in cur.fetchall()} + if not {'project_id', 'source'} <= session_cols: + return {} + where_clauses = [ + "s.source IS NOT NULL", + "s.project_id IS NOT NULL", + "TRIM(s.project_id) != ''", + ] + params: list[object] = [] + excluded = tuple( + str(source) for source in (exclude_sources or ()) if source ) - else: + if excluded: + placeholders = ", ".join("?" for _ in excluded) + where_clauses.append(f"s.source NOT IN ({placeholders})") + params.extend(excluded) cur.execute( f""" - {select_sql} + SELECT TRIM(s.project_id) AS project_id, COUNT(*) AS row_count FROM sessions s - {join_clause} WHERE {' AND '.join(where_clauses)} - {group_by_clause} - {order_by_clause} + GROUP BY TRIM(s.project_id) """, params, ) - projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()]) - # model_config is selected only so the continuation classifier can - # read the real _branched_from/_delegate_from markers; it is internal - # agent state and must not leak into the /api/sessions response. - for row in projected: - row.pop('model_config', None) - projected = [_with_normalized_source(row) for row in projected] - projected = [row for row in projected if is_cli_session_row_visible(row)] - if limit is None: - return projected - selected = projected[:max(0, int(limit))] - - # The recency slice is per-row, but subagent rows are only renderable as - # children: the sidebar nests a child under its parent solely when that - # parent row is present in the same payload. A frozen orchestrator stops - # writing while its leaves keep streaming, so the leaves win the recency - # race and the parent falls outside the window — leaving the leaves to be - # promoted to top-level sidebar rows. Re-add subagent parents that the - # oversampled candidate set already projected (no extra query); webui - # ancestors are left out because that sidebar bucket already has them. - # - # Bounded by construction: ``by_id`` only holds the ``limit * 8`` - # newest candidates, so an ancestor older than that oversample is not - # recovered and its children stay top-level — the pre-existing - # behaviour, narrowed rather than fixed. Resolving those would need an - # unbounded per-row ancestor query on the hot sidebar path; widen - # ``candidate_limit`` instead if the window proves too tight. - # NOTE: this can return more than ``limit`` rows (see docstring). - have = {row.get('id') for row in selected} - by_id = {row.get('id'): row for row in projected if row.get('id')} - pending = list(selected) - while pending: - row = pending.pop() - if str(row.get('raw_source') or row.get('source') or '').strip().lower() != 'subagent': - continue - parent_id = row.get('parent_session_id') - if not parent_id or parent_id in have: - continue - parent = by_id.get(parent_id) - if parent is None: - continue - if str(parent.get('raw_source') or parent.get('source') or '').strip().lower() != 'subagent': - continue - selected.append(parent) - have.add(parent_id) - pending.append(parent) - return selected - + return { + str(row[0]): int(row[1] or 0) + for row in cur.fetchall() + if str(row[0] or '').strip() + } + except Exception: + log.debug("assigned project row-count probe failed", exc_info=True) + return {} def _lineage_report_row(row: dict, role: str) -> dict: diff --git a/api/models.py b/api/models.py index a65d6071..8dcd54b2 100644 --- a/api/models.py +++ b/api/models.py @@ -16,7 +16,7 @@ import uuid from contextlib import closing, contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Literal, overload +from typing import Literal, cast, overload try: # pragma: no cover - platform-specific imports. import fcntl as _fcntl @@ -42,6 +42,7 @@ from api.agent_sessions import ( is_cli_session_row, normalize_agent_session_source, open_state_db_readonly, + read_assigned_project_row_counts, read_importable_agent_session_rows, read_session_lineage_metadata, ) @@ -53,6 +54,63 @@ 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 (#6659). +# +# The bound is PER PROJECT and counts LOGICAL conversations (after lineage and +# sidecar dedup), not raw state.db rows. Per project, because a single global +# budget re-creates the bug this fixes — one busy project would evict another +# project's whole history. Logical conversations, because compression segments +# are not separately addressable and must not spend a project's budget. +PROJECT_ASSIGNED_CLI_LIMIT = 200 +# Hard ceiling on the assigned-recovery QUERY so the per-project budget cannot +# multiply into an unbounded scan on a profile with dozens of projects. Past +# this, every project's share of the recovery pass shrinks to +# PROJECT_ASSIGNED_CLI_SCAN_CEILING // len(projects) instead of any one project +# eating the whole window; project-filtered server pagination is still the real +# fix for profiles that large. +PROJECT_ASSIGNED_CLI_SCAN_CEILING = 2000 +# The project-scoped follow-up below is deliberately NOT capped by project +# count: any fixed cap would recreate the starvation it repairs — every starved +# project past the cap stays unreachable on every rebuild (greptile P1 on +# #6659). Its work is bounded by construction instead: it fires only when the +# global assigned query is logically saturated OR its final raw candidate window +# was exhausted while projection stayed short; each scoped query is limited to +# one project's remaining budget; and the sum of those budgets across every +# starved project is at most effective_limit * len(projects), which the scan +# ceiling above already caps. Worst case per build: 1 global query + 1 GROUP BY +# probe + one small project-scoped query per starved project, plus the pass's +# single widening budget (query_limit, see `widening_budget`) shared by the +# compression-heavy retries — a retry is NOT granted the global allowance per +# project, which would make a build scale with the number of starved projects +# (greptile P1 on #6659). +# +# --- Bounds for the UNASSIGNED refill pass (see _load_cli_sessions_uncached). +# The SQL 'unassigned' filter reads state.db.project_id, but a WebUI-side move +# records the assignment on the session's sidecar ONLY, so that filter hands back +# conversations this projection classifies as ASSIGNED. How many is not knowable +# before the query runs, so the refill re-classifies its own result and widens +# again while the window is still short. These two constants are what keep that +# loop finite. Both bound ONE profile context: get_cli_sessions( +# all_profiles=1) runs the whole loader once per context, so an N-profile sidebar +# pays N times the numbers below. +# +# At most this many unassigned queries per profile context (the first included). +# The widening below is geometric and proportional, not one row at a time, so 4 +# covers both moves spread through the history and moves clustered just under the +# window edge; the last of the four abandons estimating and reads the ceiling in +# one go, which is the widest read the loop is ever allowed to pay. It is also +# what paces the geometric growth: each widening adds at least width // queries +# still left, so the steps get bolder as the forced ceiling read gets closer. +UNASSIGNED_CLI_REFILL_MAX_QUERIES = 4 +# ...and no single refill query reads deeper than this many logical +# conversations. It bounds both the I/O and the payload: a build cannot be made +# to scan the whole of a large state.db by moving conversations into projects. +# The unassigned window is filled whenever at least CLI_VISIBLE_SESSION_LIMIT of +# the newest UNASSIGNED_CLI_REFILL_SCAN_CEILING state.db-unassigned conversations +# are still unassigned once sidecars are read; past that (>180 of the newest 200 +# moved from the WebUI) the sidebar is honestly short rather than unbounded. +UNASSIGNED_CLI_REFILL_SCAN_CEILING = 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 @@ -63,6 +121,11 @@ WEBHOOK_PROJECT_CHIP_LIMIT = 200 # higher project-chip cap so project-assigned kanban rows stay addressable when # the toggle is on, without letting them dominate the default sidebar window. KANBAN_PROJECT_CHIP_LIMIT = 200 +# Sources with their own bounded project-chip pass. They are kept out of the +# interactive CLI window and out of its recovery passes, and they keep the +# system-chip answer wherever they are projected, so the same background row can +# never report two different project chips. +BACKGROUND_CLI_SOURCES = ("cron", "webhook", "kanban") _CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0 # While a turn is actively streaming, hold the CLI/cron projection longer than # one poll interval (mirrors the route-level #4808 hold-down). The frontend @@ -7350,6 +7413,48 @@ def _profile_has_user_projects(profile: str | None = None) -> bool: return False +# Sentinel for profile_scoped_project_ids(): "no profile given, use the active +# one". A bare None cannot mean that, because None is a legitimate profile value +# (_profiles_match reads it as the root profile). +_ACTIVE_PROFILE = object() + + +def profile_scoped_project_ids(profile=_ACTIVE_PROFILE) -> frozenset[str]: + """Project IDs that currently exist for ``profile`` (default: the active one). + + A state.db ``project_id`` is an opaque string the agent wrote; the project + it names can since have been deleted, or can belong to a different profile. + Callers resolve against this set and treat a miss as "unassigned" so a stale + assignment can never HIDE a session: an unresolved id would otherwise drop + the row out of "Unassigned" and turn it into a ``default_hidden`` row with no + project chip left to reveal it (#6659 review finding 3). + + ``profile`` must be the SESSION's profile, not the process-wide active one, + wherever the two can differ. The all-profiles sidebar view scans one state.db + per profile in a single request, so resolving every context against the active + profile made another profile's LIVE assignment look unresolvable and dropped + the row from the payload entirely — while its chip stayed selectable in that + same view (#6659 review finding 3). + + Profile/alias matching is delegated to ``api.profiles._profiles_match``, + the canonical helper every other profile-scoped read already uses, so this + set cannot disagree with the rest of the app about which profile owns a + project (renamed root, legacy ``'default'`` tag, and a missing ``profile`` + key are all its business, not ours). Read-only. + """ + from api.profiles import get_active_profile_name, _profiles_match + + active = get_active_profile_name() if profile is _ACTIVE_PROFILE else profile + resolved: set[str] = set() + for p in load_projects(): + project_id = str(p.get('project_id') or '').strip() + if not project_id: + continue + if _profiles_match(p.get('profile'), active): + resolved.add(project_id) + return frozenset(resolved) + + def is_cron_session(session_id: str, source_tag: str | None = None) -> bool: """Return True if a session originates from a cron job.""" if source_tag == 'cron': @@ -8171,7 +8276,14 @@ def clear_sidecar_metadata_cache() -> None: def _state_projection_sidecar_metadata(sid: str) -> dict: - """Return UI-owned metadata (title + archived) for a state.db-projected row. + """Return UI-owned metadata (title + archived + project_id) for a state row. + + ``project_id`` is UI-owned in exactly the same way as the other two: + ``/api/session/move`` writes it onto the sidecar and NOTHING writes + ``state.db.sessions.project_id``. Reading it here is what lets the sidebar + projection agree with the assignment ``all_sessions()`` re-surfaces, instead + of counting a moved conversation against the unassigned window (#6659 review + finding 2). Memoized by the sidecar file's (path, mtime_ns, size, ctime_ns) stat signature so the sidebar projection — which calls this once per row in both @@ -8182,11 +8294,11 @@ def _state_projection_sidecar_metadata(sid: str) -> dict: Returns a COPY so callers can't mutate the cached dict. NOTE: this stat-gates on ``SESSION_DIR / f'{sid}.json'`` because that file is - ``Session.load_metadata_only``'s sole source for title+archived. If that ever - stops being true (metadata moves to another store), this gate would short- - circuit before the real source — update both together. + ``Session.load_metadata_only``'s sole source for title+archived+project_id. If + that ever stops being true (metadata moves to another store), this gate would + short-circuit before the real source — update both together. """ - default = {"title": None, "archived": None} + default = {"title": None, "archived": None, "project_id": None} if not is_safe_session_id(sid): return dict(default) p = SESSION_DIR / f'{sid}.json' @@ -8214,6 +8326,8 @@ def _state_projection_sidecar_metadata(sid: str) -> dict: if title: metadata["title"] = title metadata["archived"] = bool(getattr(webui_meta, 'archived', False)) + project_id = str(getattr(webui_meta, 'project_id', None) or '').strip() + metadata["project_id"] = project_id or None with _SIDECAR_METADATA_CACHE_LOCK: # Re-check under lock in case a concurrent build populated it; either @@ -8226,6 +8340,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, @@ -8234,6 +8358,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, @@ -8321,13 +8446,98 @@ def _load_cli_sessions_uncached( _webhook_pid_cache[0] = ensure_webhook_project(profile=_cli_profile) return _webhook_pid_cache[0] + # The live project catalog for THIS SCAN'S profile, read lazily and at most + # once per scan (same [resolved, value] cell pattern as _cron_pid above, for + # the same #4842 reason: a per-row load_projects() is a cold-sidebar I/O + # blowup). + # + # Scoped to ``_cli_profile`` — the profile whose state.db this scan is + # reading — not to the process-wide active profile. The all-profiles view + # calls this loader once per profile context in a single request, so the + # active-profile catalog would misread another profile's LIVE assignment as + # unresolvable and drop the row from the payload (#6659 review finding 3). + _known_project_ids_cache: list = [False, frozenset()] + def _known_project_ids() -> frozenset[str]: + if not _known_project_ids_cache[0]: + _known_project_ids_cache[0] = True + try: + _known_project_ids_cache[1] = profile_scoped_project_ids(_cli_profile) + except Exception: + logger.debug("Project catalog read failed for CLI projection", exc_info=True) + return _known_project_ids_cache[1] + + def _resolved_project_id(raw) -> str | None: + """A state.db assignment, or None when its project no longer resolves. + + Coercing an unresolved (deleted or cross-profile) id to None BEFORE + either cap runs is what keeps the row in the default "Unassigned" window + instead of exiling it to a ``default_hidden`` row whose project chip does + not exist any more — the mirror of the bug this PR fixes (#6659). + """ + project_id = str(raw or '').strip() + if not project_id: + return None + return project_id if project_id in _known_project_ids() else None + def _state_row_project_id(sid: str, source: str | None) -> str | None: + """Dedicated system-source chip for a background row, else None. + + cron and webhook own an auto-provisioned project; kanban deliberately + does not, so it falls through to None (upstream behaviour) and is + reachable through the normal sidebar list instead of a chip. + """ if is_cron_session(sid, source): return _cron_pid() if is_webhook_session(sid, source): return _webhook_pid() return None + # Sidecar-carried assignments, memoized per sid for this scan. + # _state_projection_sidecar_metadata is already stat-cached, but + # _interactive_row_project_id is called several times per row (budget + # seeding, the unassigned count, the projection), and one os.stat per call + # per row is the kind of cold-sidebar I/O #4842 removed. + _sidecar_pid_cache: dict[str, str | None] = {} + def _sidecar_row_project_id(sid: str) -> str | None: + if sid not in _sidecar_pid_cache: + _sidecar_pid_cache[sid] = _state_projection_sidecar_metadata(sid).get('project_id') + return _sidecar_pid_cache[sid] + + def _interactive_row_project_id(row: dict) -> str | None: + """Project chip for an interactive (CLI/TUI/ACP) state.db row. + + Single resolved value for both the cap decisions below and the projected + payload, so the code that budgets a row and the code that renders it can + never disagree about which project it belongs to. + + The WebUI sidecar is consulted when state.db carries no assignment, + because ``/api/session/move`` writes ``project_id`` onto the sidecar ONLY + — nothing writes ``state.db.sessions.project_id``. Without this the + "``CLI_VISIBLE_SESSION_LIMIT`` unassigned conversations" guarantee counted + a WebUI-side assignment as unassigned while the route (which sees the + sidecar's value through ``all_sessions()``) classified the same + conversation as ASSIGNED, so three moved sessions silently shortened + everyone's sidebar to 17 rows (#6659 review finding 2). state.db wins when + it has an assignment of its own: it is the agent's own record. + + Background sources keep the system-chip answer even when a + ``source_filter`` routes them through this loop, so one kanban row cannot + report a resolved ``project_id`` here and ``None`` from its own bounded + second pass further down. + """ + sid = row['id'] + source = row.get('source') + if ( + str(source or '').strip().lower() in BACKGROUND_CLI_SOURCES + or is_cron_session(sid, source) + or is_webhook_session(sid, source) + ): + return _state_row_project_id(sid, source) + resolved = _resolved_project_id(row.get('project_id')) + if resolved is None: + resolved = _resolved_project_id(_sidecar_row_project_id(sid)) + return resolved + profile_value = _cli_profile or 'default' # A deleted WebUI session is tombstoned (see _record_webui_deleted_session_tombstone) # so recovery/audit/claim treat it as gone. The sidebar's own state.db projection @@ -8339,7 +8549,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' @@ -8351,9 +8561,536 @@ def _load_cli_sessions_uncached( # 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. + # Spelled as a literal on purpose: tests/test_issue2841_show_cron_sessions_toggle.py + # reads this line as source text. Must stay equal to BACKGROUND_CLI_SOURCES + # (pinned by test_background_source_exclusion_literal_matches_the_constant). 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: + # The interactive window above is a MIXED budget of assigned and + # unassigned conversations, and it truncates by recency. Two bounded + # recovery passes repair the two ways that loses a conversation: + # 1. an assigned conversation older than the window is unreachable even + # though its project chip still claims it; + # 2. every assigned row inside the window spends a slot the sidebar + # owes to an unassigned conversation (#6659 review findings 1-2). + # Both passes are keyed on the LOGICAL conversation (lineage), never the + # raw row, so compression segments cannot consume either budget. + interactive_excluded = BACKGROUND_CLI_SOURCES + first_pass_count = len(state_rows) + represented_rows: dict[str, dict] = {} + + def _lineage_ids(row: dict) -> list[str]: + return [ + str(value) + for key in ("id", "_lineage_root_id", "_lineage_tip_id") + if (value := row.get(key)) + ] + + def _merge_state_row(row: dict) -> bool: + """Add ``row`` unless its lineage is already represented. + + Returns True only when a NEW logical conversation was added, which is + what lets recovery pass 1 spend its per-project budget on + conversations rather than rows. An already represented lineage is + upgraded in place: the recovery pass sees the same conversation + carrying its resolved project assignment, and the projection loop + must read that richer row. Pass 2 needs no budget of its own (its + query limit is the bound) and ignores the result. + + "Upgrade" is one-way. Overwriting a row that already carries a + RESOLVED project_id with a projection that has none would delete the + chip this whole function exists to restore — and swap the row's + identity from the lineage tip back to its root. Pass 2 projects the + same conversations as pass 1 without the assignment context, so it + must never be able to win that race. + """ + lineage_ids = _lineage_ids(row) + existing = next( + (represented_rows[value] for value in lineage_ids if value in represented_rows), + None, + ) + if existing is not None: + if ( + _resolved_project_id(row.get('project_id')) is not None + or _resolved_project_id(existing.get('project_id')) is None + ): + # Either the incoming row adds an assignment, or the row on + # file has none to lose: overwriting cannot drop a chip. + existing.clear() + existing.update(row) + for value in lineage_ids: + represented_rows[value] = existing + return False + state_rows.append(row) + for value in lineage_ids: + represented_rows[value] = row + return True + + for row in state_rows: + for value in _lineage_ids(row): + represented_rows.setdefault(value, row) + + # --- Recovery pass 1: project-assigned conversations the recent window + # truncated. Bounded PER PROJECT (see PROJECT_ASSIGNED_CLI_LIMIT) so a + # chip can reveal its project's history without any one project growing + # the payload without limit. Skipped entirely when the profile has no + # projects at all — nothing could resolve, so the query would be waste. + # + # Two tiers, because a per-project BUDGET on top of one global recency + # window is not a per-project bound: a project that owns the whole window + # leaves every other project unrepresented. So the global query runs + # first (the only cost most profiles pay), and only if it came back + # saturated does a project-scoped follow-up top up the projects it + # starved. Worst case per build: 1 global query + 1 GROUP BY probe + + # one project-scoped query per starved project, each bounded to that + # project's remaining budget (whose sum the scan ceiling bounds). + known_project_ids = _known_project_ids() if project_assigned_limit is not False else frozenset() + if ( + project_assigned_limit is not False + and known_project_ids + and _state_db_supports_project_ids(db_path) + ): + project_count = max(len(known_project_ids), 1) + per_project_limit = ( + None if project_assigned_limit is None + else max(0, int(project_assigned_limit)) + ) + # Every project gets an EQUAL share of the global scan ceiling. The + # share only bites past PROJECT_ASSIGNED_CLI_SCAN_CEILING / + # PROJECT_ASSIGNED_CLI_LIMIT projects, and it is what keeps the + # recovered payload bounded (<= the ceiling) while making a project's + # allowance independent of how loud its neighbours are. + effective_limit = ( + None if per_project_limit is None + else min( + per_project_limit, + max(1, PROJECT_ASSIGNED_CLI_SCAN_CEILING // project_count), + ) + ) + query_limit = ( + None if effective_limit is None + else min( + effective_limit * project_count, + PROJECT_ASSIGNED_CLI_SCAN_CEILING, + ) + ) + try: + # An assigned conversation ALREADY inside the recent window + # occupies one of its project's slots, so this pass tops each + # project UP TO the bound instead of stacking a second bound on + # top of the window. + kept_per_project: dict[str, int] = {} + for row in state_rows: + seeded_project_id = _interactive_row_project_id(row) + if seeded_project_id is not None: + kept_per_project[seeded_project_id] = ( + kept_per_project.get(seeded_project_id, 0) + 1 + ) + + def _spend_assigned_rows(rows: list[dict]) -> None: + """Merge ``rows`` into the window under the per-project budget.""" + for row in rows: + project_id = _resolved_project_id(row.get('project_id')) + if project_id is None: + # A deleted/cross-profile id is not an assignment. + # Leave the row to the normal window so it stays + # reachable under "Unassigned" (#6659 finding 3). + continue + if ( + effective_limit is not None + and kept_per_project.get(project_id, 0) >= effective_limit + ): + continue + if _merge_state_row(row): + # Spend the budget on LOGICAL conversations only: a + # row that merely upgrades an already-represented + # lineage (a compression segment, or a window row + # learning its assignment) costs its project nothing. + kept_per_project[project_id] = ( + kept_per_project.get(project_id, 0) + 1 + ) + + # One global newest-first query covers every profile whose whole + # assigned history fits in the window — the overwhelmingly common + # case, and the only query most builds pay. + global_rows, global_window_exhausted = cast( + tuple[list[dict], bool], + read_importable_agent_session_rows( + db_path, + limit=query_limit, + log=logger, + exclude_sources=interactive_excluded, + project_assignment='assigned', + return_window_exhaustion=True, + ), + ) + _spend_assigned_rows(global_rows) + + # A full logical result truncates by recency, as does a result + # that stays short only because the final raw candidate window + # was consumed by compression/visibility projection. In either + # case older rows can still exist, so only then pay the GROUP BY + # probe that identifies projects needing a scoped top-up. A short + # result from an unfilled raw window has seen every candidate and + # keeps the one-global-query fast path. + if ( + effective_limit + and query_limit + and ( + len(global_rows) >= query_limit + or global_window_exhausted + ) + ): + # One GROUP BY over sessions.project_id (no join, no lineage + # recursion) says which projects still own rows this pass has + # not delivered, so the follow-up queries below are paid only + # for projects that can actually be starved — not one per + # registered project. + owed_rows = read_assigned_project_row_counts( + db_path, + log=logger, + exclude_sources=interactive_excluded, + ) + starved = sorted( + (kept_per_project.get(project_id, 0), project_id) + for project_id, row_count in owed_rows.items() + if project_id in known_project_ids + and kept_per_project.get(project_id, 0) < effective_limit + and row_count > kept_per_project.get(project_id, 0) + ) + # Neediest first (fewest conversations delivered, then id for + # a deterministic order). EVERY starved project is served: a + # fixed per-build project cap would leave every project past + # it unreachable on each rebuild — the very starvation this + # pass exists to repair (greptile P1 on #6659). The per-build + # cost stays bounded because each query is limited to one + # project's remaining budget (sum <= the scan ceiling) and + # only fires when the global window was saturated. + # + # The WIDENING below is rationed per PASS instead (greptile + # P1 on #6659): `remaining` bounds each project's FIRST + # query, but a retry that jumps to `query_limit` is not + # bounded by the project's own share at all, so granting it + # to every starved project made a build scan + # len(starved) * query_limit — an order of magnitude past + # the ceiling on a profile with ten compression-heavy + # projects. One aggregate pool caps the scoped reading of + # a pass at a constant multiple of `query_limit` however + # many projects are starved. + # Only the widening is rationed; the first query of every + # starved project is never skipped, which is what keeps the + # completeness guarantee above intact. + # + # (greptile P1, 2026-09-22 re-review) The pool is debited + # only for what a retry reads BEYOND the project's own + # first-query width — `widened - scoped_limit`, not the + # whole `widened` grant. Debiting the whole grant spent the + # entire pool on the first starved project whose window + # merely touched its own 8x oversample: it read + # `grant - share` extra rows but was billed the full grant, + # so every later starved project lost its retry and its + # older conversations stayed absent from its sidebar on + # every rebuild. With incremental debits a later project + # retries whenever real reads have left it pool; if an + # earlier project genuinely read the pool down to zero, + # that was real scan work — the aggregate bound, not a + # reservation, is what caps the pass. The bound is + # `3 * query_limit`: every starved project's first query + # sums to at most `query_limit`, a retrying project re-reads + # at most its own first width again, and the funded + # increments sum to at most the pool — still constant in + # the number of starved projects, which is what retires the + # original `len(starved) * query_limit` blow-up. + widening_budget = query_limit + for starved_index, (_kept, project_id) in enumerate(starved): + remaining = effective_limit - kept_per_project.get(project_id, 0) + if remaining <= 0: + continue + # A scoped query carries a raw candidate window of its + # own, and compression segments plus the post-projection + # visibility filters are spent from that window BEFORE + # the logical slice. A lineage-heavy project can + # therefore come back short with its window fully + # consumed and older assigned conversations of its own + # still waiting behind it. Without the exhaustion signal + # the loop would advance to the next project and leave + # this one under-delivered on every rebuild — the same + # failure the global query already guards against + # (greptile P1 on #6659). Re-query once, widened to the + # project's own width plus whatever the pass's widening + # pool still has, which is what bounds the retry at the + # PASS level instead of per project (greptile P1 on + # #6659). + scoped_limit = remaining + while True: + scoped_rows, scoped_window_exhausted = cast( + tuple[list[dict], bool], + read_importable_agent_session_rows( + db_path, + limit=scoped_limit, + log=logger, + exclude_sources=interactive_excluded, + project_assignment='assigned', + project_ids=(project_id,), + return_window_exhaustion=True, + ), + ) + _spend_assigned_rows(scoped_rows) + if ( + not scoped_window_exhausted + # A binding window on a project that is now + # full needs no second query, and neither does + # one that cannot be widened any further. + or kept_per_project.get(project_id, 0) + >= effective_limit + or scoped_limit >= query_limit + ): + break + # (greptile P1, 2026-09-22 re-review) Each + # retry is funded by a FAIR SHARE of what the + # pool still holds — one slice per starved + # project not yet tried, so later projects + # always keep pool for their own first retry. + # The retry goes to `scoped_limit + share`: + # debiting the whole grant spent the entire + # pool on the first starved project whose + # window merely touched its own 8x oversample + # and left every later one unfunded, but + # granting `min(query_limit, pool)` whole also + # let the first project READ the pool down to + # zero — every project whose raw window is + # consumed re-reads its own width once before + # delivering, so an honest retry must buy + # roughly one more width. The share converges + # there in a few bounded steps; anything still + # short after the pool is spent stays for the + # next build — the alternative is the + # per-project grant that made the pass scan + # `len(starved) * query_limit` (the original + # greptile P1 on #6659). + pool_share = ( + widening_budget + // max(len(starved) - starved_index, 1) + ) + if pool_share <= 0: + # The pool is spent: no further starved + # project's retry can be funded. Whatever a + # project could not reach this pass stays + # for the next build — the alternative is + # buying reads nobody budgeted for. + break + widened = min( + query_limit, scoped_limit + pool_share + ) + widening_budget -= widened - scoped_limit + scoped_limit = widened + except Exception: + logger.debug("Project-assigned CLI recovery pass failed", exc_info=True) + + # --- Recovery pass 2: top the interactive window back up to + # CLI_VISIBLE_SESSION_LIMIT *unassigned* conversations. Only runs when + # the window came up short AND something other than an empty database + # can explain it: either it was saturated, or assigned conversations + # inside it displaced unassigned candidates. A profile with no + # assignments and a small state.db still pays nothing. + unassigned_target = ( + visible_session_limit if visible_session_limit is not None + else CLI_VISIBLE_SESSION_LIMIT + ) + + def _count_unassigned() -> int: + """Conversations in the window this projection calls unassigned. + + Recomputed after every refill query instead of trusted from before + it: the classification depends on the sidecar, so only the rows in + hand can answer it. Cheap to repeat — both inputs + (``_sidecar_row_project_id``, ``_known_project_ids``) are memoized + for the scan, so no pass re-reads a file (#4842). + """ + return sum( + 1 for row in state_rows if _interactive_row_project_id(row) is None + ) + + unassigned_seen = _count_unassigned() + # A sidecar-carried assignment is invisible to the SQL 'unassigned' + # filter (state.db still says NULL), so the refill query would spend + # that many of its own slots re-fetching conversations this pass already + # classified as assigned and already represents. Widen the FIRST query by + # that count so it can actually reach the older unassigned conversations + # the moved rows displaced (#6659 review finding 2). It is only the first + # estimate — the moved conversations this window cannot see yet are what + # the re-examination loop below is for. + sidecar_only_assigned = sum( + 1 for row in state_rows + if _interactive_row_project_id(row) is not None + and _resolved_project_id(row.get('project_id')) is None + ) + if ( + unassigned_target + and unassigned_seen < unassigned_target + and ( + first_pass_count >= unassigned_target + # A short MIXED first pass can still be under-delivering: the + # narrower unassigned-only query reaches conversations the mixed + # candidate window spent on assigned rows and segments. + or unassigned_seen < first_pass_count + ) + ): + try: + # ONE pre-computed allowance is the wrong shape. It is derived + # from the rows fetched BEFORE the refill, but the refill reaches + # OLDER conversations, and those can carry sidecar-only + # assignments of their own — each of which spends an unassigned + # slot again, the exact failure the widening exists to remove. + # (Three moves at the bottom edge of a 30-conversation database + # delivered 19 rows and never fetched the 20th conversation; + # eleven straddling the boundary delivered 15.) + # + # So the refill re-classifies ITS OWN result and widens again + # while the window is short. Bounds, per PROFILE CONTEXT (not per + # sidebar build: all_profiles=1 runs this loader once per context, + # so an N-profile view pays N times everything below): + # * at most UNASSIGNED_CLI_REFILL_MAX_QUERIES queries; + # * none of them reading deeper than the scan ceiling; + # * and it stops early the moment the window is full or the + # filter has demonstrably nothing deeper to give. + # Guarantee: the window is filled whenever at least + # ``unassigned_target`` of the newest ceiling + # state.db-unassigned conversations are still unassigned once + # their sidecars are read — the last query reads exactly that + # band. Past that the sidebar is honestly short (a shortfall the + # database itself imposes) instead of unbounded work. + # + # What that costs, stated at its WORST rather than its best. The + # window this loop must deliver is 20 rows; the best case is a + # single query carrying 24 of them, and quoting that as the + # trade-off understates it by an order of magnitude: + # * PAYLOAD. Every refill row is merged into the projection and + # travels on, so the surplus is trimmed by the sidebar cap + # only AFTER it has been read, sidecar-stat()ed and (on + # /api/sessions/gateway/stream, which snapshots the uncapped + # list) serialised. A shape that keeps the window short + # through all three estimates reaches the forced ceiling + # query, and the model then carries the whole band — up to + # scan_ceiling (200) conversations, nearly all of them + # unassigned, to hand the sidebar 20. The widening below is + # what keeps ordinary shapes off that path; it does not, and + # cannot, remove it, because the ceiling read is exactly what + # the guarantee above is made of. + # * I/O. "No query deeper than 200 conversations" is a LOGICAL + # depth. read_importable_agent_session_rows oversamples raw + # rows by CANDIDATE_WINDOW_MULTIPLIERS = (8, 32) to survive + # compression segments, so a ceiling-width query touches + # 200 * 8 = 1600 raw rows, and 200 * 32 = 6400 when the first + # window is consumed and re-widened. Per profile context. + # Cheaper than any of it: project-filtered server pagination, or + # recording WebUI-side moves in state.db so the SQL filter stops + # lying to this pass. + query_limit = unassigned_target + sidecar_only_assigned + # A caller-supplied window wider than the ceiling still gets its + # first query at full width — the ceiling bounds the WIDENING, + # it does not shrink the requested window. + scan_ceiling = max(UNASSIGNED_CLI_REFILL_SCAN_CEILING, query_limit) + queries_left = UNASSIGNED_CLI_REFILL_MAX_QUERIES + while queries_left > 0: + queries_left -= 1 + if queries_left == 0: + # Out of estimates: pay one query at the ceiling rather + # than hand back a short window after guessing three + # times. This is the only query allowed to over-read, and + # the ceiling is what bounds it. + query_limit = scan_ceiling + refill_rows, refill_window_exhausted = cast( + tuple[list[dict], bool], + read_importable_agent_session_rows( + db_path, + limit=query_limit, + log=logger, + exclude_sources=interactive_excluded, + project_assignment='unassigned', + return_window_exhaustion=True, + ), + ) + for row in refill_rows: + _merge_state_row(row) + unassigned_seen = _count_unassigned() + shortfall = unassigned_target - unassigned_seen + if ( + shortfall <= 0 + or query_limit >= scan_ceiling + # A short result from an UNFILLED raw candidate window has + # already seen every conversation this filter can reach, + # so a wider query would re-read the same rows for + # nothing. A short result whose raw window WAS consumed + # (by compression segments) is the opposite: widening the + # limit re-widens that window, so it is worth another go. + or ( + len(refill_rows) < query_limit + and not refill_window_exhausted + ) + ): + break + # Widen GEOMETRICALLY, not by one row at a time. Up to three + # candidate widths; the widest of the ones that apply wins: + # * query_limit + shortfall — the exact width needed if + # every conversation deeper than the current one is + # unassigned. It is the FLOOR: always strictly wider, so + # the loop cannot stall, and it is exact (no over-read) + # when the moved conversations are clustered. + # * query_limit + query_limit // queries_left — GEOMETRIC + # growth, paced by the budget that is left. The floor alone + # advances by only the shortfall, so a handful of moves + # clustered just below the window edge crawls (21, 23, 25) + # until every estimate is spent and the forced ceiling read + # does the job anyway: 200 rows fetched to deliver 20. The + # step grows as the budget shrinks (width/3, then width/2) + # because a too-narrow LAST estimate costs the whole ceiling + # read, while a too-wide one costs only its own surplus. + # Those eight clustered moves now finish at (21, 28). + # * ceil(target * query_limit / unassigned_seen) — the width + # implied by the moved SHARE observed so far, for moves + # spread through the history. Without it, a database where + # every other conversation was moved would close the gap + # by halves and need ~log2(target) queries. + # That last term applies ONLY when this window found at least + # one still-unassigned conversation. With zero, the share is + # undefined, and guarding the divisor with max(seen, 1) + # evaluated it as target * query_limit (20 * 40 = 800, clamped + # to the ceiling): every profile that had moved >= 21 of its + # newest conversations jumped straight to a full 200-row read + # where the 60-row floor would have filled the window. Zero + # unassigned rows is not evidence about density, so the + # geometric step — not a degenerate ratio — is what carries it. + # The price of escalating smoothly is that a profile whose + # newest ceiling band is ENTIRELY moved now spends its whole + # budget (40, 60, 90, 200) climbing to the ceiling it was + # always going to need, instead of two queries. That is the + # deliberate side of the trade: an extra narrow query costs SQL + # only, while an early-and-wrong wide query costs payload — and + # payload is read, sidecar-stat()ed and serialised. And a + # cluster deeper than the last estimate can reach — measured at + # 23+ moves sitting just under the window edge, where the three + # estimates are 21, 28, 42 — still ends on the ceiling read, as + # it did before: that is the guarantee doing its job, not the + # estimate failing. + next_query_limit = max( + query_limit + shortfall, + query_limit + query_limit // max(queries_left, 1), + ) + if unassigned_seen > 0: + next_query_limit = max( + next_query_limit, + -( # ceil(target * width / unassigned) + -unassigned_target * query_limit // unassigned_seen + ), + ) + query_limit = min(scan_ceiling, next_query_limit) + except Exception: + logger.debug("Unassigned CLI refill 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 @@ -8398,7 +9135,7 @@ def _load_cli_sessions_uncached( 'updated_at': raw_ts, 'pinned': False, 'archived': _archived, - 'project_id': _state_row_project_id(sid, _source), + 'project_id': _interactive_row_project_id(row), 'profile': profile, 'source_tag': _source, 'raw_source': row.get('raw_source') or _source_meta.get('raw_source'), @@ -8696,8 +9433,17 @@ def get_cli_sessions( merged: list[dict] = [] for idx, (ctx_home, ctx_db_path, ctx_profile) in enumerate(contexts): load_kwargs = { + # NOTE: visible_session_limit=None is NOT "unbounded" for the + # interactive pass — it resolves to CLI_VISIBLE_SESSION_LIMIT + # above, so this view truncates assigned conversations by + # recency exactly like the single-profile one and needs the + # same recovery passes. project_assigned_limit therefore keeps + # its default per-project bound here. Only the three limits + # below are handed straight to the reader as ``limit=``, where + # None really does mean unbounded. 'source_filter': source_filter, 'visible_session_limit': None, + 'project_assigned_limit': PROJECT_ASSIGNED_CLI_LIMIT, 'cron_project_limit': None, 'webhook_project_limit': None, 'kanban_project_limit': None, @@ -8713,7 +9459,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( diff --git a/api/routes.py b/api/routes.py index 2450cae5..17aafb54 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10488,19 +10488,164 @@ def _cli_visible_session_cap() -> int: return CLI_VISIBLE_SESSION_LIMIT -def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int | None = None) -> list[dict]: - """Keep only the most recent CLI-visible sessions after filtering.""" +# Bound on project-assigned CLI rows in the FINAL MERGED payload, across EVERY +# project. This is the only place that sees every source of assigned rows at +# once — state.db's own bounded passes plus imported WebUI sidecars from +# all_sessions(), which no model-side cap applies to — so it is the only place +# that can actually bound the assigned set (#6659 review finding 1). +# +# It has to bound the MERGED set, not one project: 200 rows x N projects grows +# with the project count, and 1,000 assigned conversations spread over 5 projects +# still returned all 1,000 — the exact reproduction from that finding. Pinned +# equal to models.PROJECT_ASSIGNED_CLI_LIMIT (an independent literal on the +# model side; the test is what keeps the two in lockstep) by +# test_route_merged_assigned_cap_is_the_existing_model_row_cap. +CLI_PROJECT_ASSIGNED_CAP = 200 + + +def _draw_assigned_cli_rows_fairly( + rows_by_project: dict[str, list[int]], budget: int +) -> set[int]: + """Pick ``budget`` assigned row indices, spread fairly across the projects. + + ``rows_by_project`` maps project id -> that project's row indices, newest + first, keyed in order of each project's most recent assigned conversation + (``sessions`` is newest-first, so insertion order already is that order). + + Each round hands one slot to every project that still has history left, so: + + * the drawn set never exceeds ``budget`` — that is the whole point, a + per-project bound does not bound the payload (#6659 review finding 1); + * no single busy project can eat every slot, which a flat ``sessions[:200]`` + truncation would do to whichever project sorts first — the starvation + greptile rejected as P1 on #6659; + * every project keeps at least one row whenever + ``budget >= len(rows_by_project)``. Past that the bound wins: with more + assigned projects than slots, the ``budget`` most recently active projects + get one row each, because the review's number is the hard constraint. + + Within a project the draw is newest-first, so what a chip loses is always the + oldest end of its own history. + """ + drawn: set[int] = set() + if budget <= 0 or not rows_by_project: + return drawn + queues = list(rows_by_project.values()) + offsets = [0] * len(queues) + remaining = budget + while remaining > 0: + progressed = False + for position, project_rows in enumerate(queues): + offset = offsets[position] + if offset >= len(project_rows): + continue + drawn.add(project_rows[offset]) + offsets[position] = offset + 1 + remaining -= 1 + progressed = True + if remaining <= 0: + break + if not progressed: + # Every project is exhausted — the whole assigned set fits. + break + return drawn + + +def _cap_recent_cli_sessions( + sessions: list[dict], + cli_cap: int | None = None, + project_cap: int = CLI_PROJECT_ASSIGNED_CAP, +) -> list[dict]: + """Cap the default CLI list while retaining project-addressable rows. + + ``sessions`` is newest-first and already deduplicated (WebUI sidecars merged, + lineages collapsed, messaging sources folded), so every row counted here is + one logical conversation. + + Two independent budgets, because they answer to different users (#6659): + + * ``cli_cap`` unassigned conversations own the default sidebar window. An + assigned row must not spend one of those slots, or assigning three sessions + to a project silently shortens everyone's sidebar to 17 rows. Resolved + lazily from the shared configurable window (HERMES_WEBUI_VISIBLE_SESSION_LIMIT), + never a second hard-coded 20. + * ``project_cap`` assigned conversations IN TOTAL, across every project, stay + in the payload so the project chips can reveal them, marked + ``default_hidden`` once the recent window is full. Past that bound they are + dropped: keeping assigned rows past the *recent* cap is the fix, keeping + them past *all* bounds just trades a vanishing session for a stalled + sidebar. + + That assigned budget is spent by a fair round-robin draw across the projects + (see ``_draw_assigned_cli_rows_fairly``) instead of by truncating the merged + list, so bounding the payload cannot starve a quiet project (greptile P1 on + #6659). ``project_cap <= 0`` disables the assigned bound entirely. + """ if cli_cap is None: cli_cap = _cli_visible_session_cap() if cli_cap <= 0: return sessions - kept = [] - cli_seen = 0 - for session in sessions: - if _is_cli_session_for_settings(session): - cli_seen += 1 - if cli_seen > cli_cap: + # Group the assigned rows per project first: the draw has to weigh the + # projects against each other, which a single forward pass cannot do. + rows_by_project: dict[str, list[int]] = {} + for index, session in enumerate(sessions): + if not _is_cli_session_for_settings(session): + continue + project_id = str(session.get("project_id") or "").strip() + if project_id: + rows_by_project.setdefault(project_id, []).append(index) + if project_cap > 0 and rows_by_project: + # Reserve the CLI rows the recent window already shows — the first + # ``cli_cap`` CLI rows in sort order, assigned or not — before the fair + # draw spreads the REST of the assigned budget across projects. Without + # this, a project holding all the newest sessions can lose its newest + # rows in the draw and the payload drops sessions the base displays + # (2026-09-24 re-gate reproduction: 11 projects x 20 sessions, all 20 + # newest in one project, ``p0-19`` vanished from the payload). + reserved: set[int] = set() + seen_cli = 0 + for index, session in enumerate(sessions): + if not _is_cli_session_for_settings(session): continue + if seen_cli >= cli_cap: + break + seen_cli += 1 + reserved.add(index) + # The reserved rows have already been paid for by the recent window; + # draw the remaining budget over the queues MINUS those rows, so the + # reservation cannot double-spend slots the draw would have granted. + remaining_rows_by_project: dict[str, list[int]] = { + project: [index for index in indices if index not in reserved] + for project, indices in rows_by_project.items() + } + assigned_reserved = sum( + 1 for index in reserved + if str(sessions[index].get("project_id") or "").strip() + ) + drawn = reserved | _draw_assigned_cli_rows_fairly( + remaining_rows_by_project, max(project_cap - assigned_reserved, 0) + ) + else: + drawn = None if project_cap <= 0 else set() + kept = [] + recent_seen = 0 + unassigned_seen = 0 + for index, session in enumerate(sessions): + if _is_cli_session_for_settings(session): + project_id = str(session.get("project_id") or "").strip() + if not project_id: + unassigned_seen += 1 + if unassigned_seen > cli_cap: + continue + recent_seen += 1 + else: + if drawn is not None and index not in drawn: + continue + if recent_seen >= cli_cap: + session = dict(session) + session["default_hidden"] = True + else: + recent_seen += 1 kept.append(session) return kept diff --git a/tests/test_issue2628_cli_sessions_perf.py b/tests/test_issue2628_cli_sessions_perf.py index 6f31f031..8bb51090 100644 --- a/tests/test_issue2628_cli_sessions_perf.py +++ b/tests/test_issue2628_cli_sessions_perf.py @@ -239,37 +239,6 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): assert cap == 160, candidates_sql -def test_importable_agent_rows_candidate_window_overfilters_then_fills(tmp_path): - """Rows dropped after projection must not shrink the returned page. - - The SQL candidate window is oversampled (limit * 8) precisely so sessions - filtered client-side of the SQL (invisible CLI rows here) only consume - candidates, not result slots: a page that would be short if the cap were - the plain ``limit`` still fills to ``limit`` visible rows. - """ - db = tmp_path / "state.db" - _make_state_db(db, sessions=120, messages_per_session=5) - - # 30 newest sessions are CLI rows that the projection hides (ended, default - # title, no user turn visible): they must fall out of the window without - # starving the visible page below them. - conn = sqlite3.connect(str(db)) - for i in range(90, 120): - conn.execute( - "UPDATE sessions SET title = 'cli session', ended_at = 1.0, end_reason = 'timeout' WHERE id = ?", - (f"cli_perf_{i:04d}",), - ) - conn.commit() - conn.close() - - rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",)) - - assert len(rows) == 20 - assert rows[0]["id"] == "cli_perf_0089" - assert rows[-1]["id"] == "cli_perf_0070" - assert {row["actual_message_count"] for row in rows} == {5} - - def test_importable_agent_rows_candidate_ordering_stays_under_progress_budget(tmp_path, monkeypatch): """Cron-only missing-index scans should fail under the old shape budget, then pass after pre-aggregation.""" db = tmp_path / "state.db" @@ -373,3 +342,119 @@ def test_importable_agent_rows_zero_limit_skips_query_work(tmp_path): _make_state_db(db, sessions=5, messages_per_session=1) assert agent_sessions.read_importable_agent_session_rows(db, limit=0, exclude_sources=("webui",)) == [] + + +def _lineage_and_subagent_db(path): + """Two 12-segment compression lineages own the 24 newest raw rows. + + They collapse to 2 logical conversations, so a ``limit=3`` request consumes + the whole ``limit * 8`` window and still under-delivers. A hot subagent leaf + and its quiet subagent parent sit BELOW that window, reachable only once + ``CANDIDATE_WINDOW_MULTIPLIERS`` re-widens it. + """ + conn = sqlite3.connect(str(path)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ); + """ + ) + + def add(sid, title, started, source, parent=None, ended_at=None, end_reason=None): + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, message_count, " + "parent_session_id, ended_at, end_reason) VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)", + (sid, source, title, started, parent, ended_at, end_reason), + ) + for index, role in enumerate(("user", "assistant")): + conn.execute( + "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hi', ?)", + (f"{sid}_m{index}", sid, role, started + (index + 4) / 10), + ) + + for name, base in (("a", 9000.0), ("b", 8000.0)): + for index in range(12): + last = index == 11 + started = base + index + add( + f"lin{name}_seg{index}", + f"Lineage {name} part {index}", + started, + "cli", + parent=None if index == 0 else f"lin{name}_seg{index - 1}", + ended_at=None if last else started + 0.9, + end_reason=None if last else "compression", + ) + add("hot_leaf", "Hot leaf", 7000.0, "subagent", parent="quiet_orch") + add("quiet_orch", "Quiet orchestrator", 100.0, "subagent") + conn.commit() + conn.close() + + +def test_rewidened_candidate_window_still_recovers_subagent_parents(tmp_path): + """The parent-recovery walk must run on the window that ACTUALLY executed. + + ``read_importable_agent_session_rows`` re-adds subagent parents that the + oversampled candidate set already projected, so a frozen orchestrator is not + evicted while its streaming leaves stay (upstream #7089 / supersedes #7031). + That walk reads the projection of the candidate window, and #6659 turned the + single window into a re-widening loop. This pins that the walk consumes the + LAST window rather than a stale first pass: on the 8x window neither the leaf + nor its parent is even fetched, and only the widened window can anchor them. + """ + db = tmp_path / "state.db" + _lineage_and_subagent_db(db) + + rows = agent_sessions.read_importable_agent_session_rows(db, limit=3, exclude_sources=None) + ids = [row["id"] for row in rows] + + # The 8x window (24 raw rows) is exactly the two lineages: under-delivered. + assert ids[:2] == ["lina_seg11", "linb_seg11"] + # The widened window reaches the leaf... + assert "hot_leaf" in ids + # ...and the walk re-adds its quiet parent even though the recency slice + # (limit=3) had already dropped it, so the leaf still renders as a child. + assert "quiet_orch" in ids + assert next(row for row in rows if row["id"] == "hot_leaf")["parent_session_id"] == "quiet_orch" + # Contract from the docstring: the anchor pushes the result past ``limit``. + assert len(rows) == 4 + + +def test_first_candidate_window_alone_cannot_anchor_the_subagent_parent(tmp_path): + """Negative control: without the re-widening pass the same db under-delivers. + + Pins that the previous test is not vacuous — the 8x window really does stop + at the two lineages, so the parent recovery above is attributable to the + widened window and not to the first one. + """ + db = tmp_path / "state.db" + _lineage_and_subagent_db(db) + + single_window = agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS[:1] + original = agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS + agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS = single_window + try: + rows = agent_sessions.read_importable_agent_session_rows(db, limit=3, exclude_sources=None) + finally: + agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS = original + + ids = [row["id"] for row in rows] + assert ids == ["lina_seg11", "linb_seg11"] + assert "hot_leaf" not in ids + assert "quiet_orch" not in ids diff --git a/tests/test_issue4842_cron_projection_perf.py b/tests/test_issue4842_cron_projection_perf.py index 8b46b9a1..953bfe6a 100644 --- a/tests/test_issue4842_cron_projection_perf.py +++ b/tests/test_issue4842_cron_projection_perf.py @@ -201,7 +201,7 @@ def test_missing_sidecar_returns_default_without_caching_growth(tmp_path): meta = models._state_projection_sidecar_metadata("cron_nope_999") # No sidecar means no opinion on archived (None), so the projection falls back # to the state.db row's archived flag (#7548) instead of forcing it to False. - assert meta == {"title": None, "archived": None} + assert meta == {"title": None, "archived": None, "project_id": None} # No file → nothing cached (so the cache can't be poisoned by absent files). assert len(models._SIDECAR_METADATA_CACHE) == 0 diff --git a/tests/test_project_assigned_cli_session_limit.py b/tests/test_project_assigned_cli_session_limit.py new file mode 100755 index 00000000..84d0dcfe --- /dev/null +++ b/tests/test_project_assigned_cli_session_limit.py @@ -0,0 +1,2641 @@ +"""Regression: project-assigned CLI sessions must survive the recent-session cap. + +Two independent 20-session caps used to hide older project-assigned CLI/TUI +sessions while their project chip still claimed them. Recovering them must not +swing to the opposite failure, so these tests pin BOTH edges of the contract: + +* an assigned conversation older than the recent window is still reachable + (the original bug), including past the 200-conversation mark when the + assignments are spread over several projects; +* one busy project cannot starve the others: a per-project budget applied to a + single global recency window is not a per-project bound, so a saturated window + is followed by project-scoped, per-project-budgeted queries (greptile P1); +* the follow-up is COMPLETE — every starved project is served, with no fixed + per-build project cap that would permanently skip the rest (greptile P1); +* the recovered set is BOUNDED — the FINAL MERGED assigned payload never exceeds + the 200-row assigned cap, counting logical conversations, including assigned + rows that arrive as imported WebUI sidecars and therefore never pass through + any state.db cap — and that bound is spent by a fair per-project draw, so + bounding the payload does not starve a quiet project; +* assigned rows do not spend the unassigned sidebar quota: 20 unique unassigned + logical conversations survive after lineage/sidecar dedup — including when the + assignment lives only on a WebUI sidecar, wherever the moved conversations sit + relative to the window, since the refill re-classifies its OWN result and + widens again instead of trusting one pre-computed allowance. The one honest + boundary is the refill's scan ceiling: conversations deeper than + ``UNASSIGNED_CLI_REFILL_SCAN_CEILING`` are not read, so a profile whose newest + 200 state.db-unassigned conversations hold fewer than 20 still-unassigned ones + gets a shorter window (pinned below); +* a deleted or cross-profile ``project_id`` cannot hide a session — it resolves + to "unassigned" before either cap runs, instead of becoming a ``default_hidden`` + row with no chip left to reveal it; +* a project assignment recorded on a newer EMPTY compression continuation is + applied to the lineage even when the root is the freshest importable segment. +""" + +import json +import sqlite3 + +import pytest + +from api import agent_sessions, models, routes + +BASE_TS = 1700000000.0 + + +def _session( + sid, + started_at, + *, + project_id=None, + parent=None, + ended_at=None, + end_reason=None, + source="cli", + messages=1, + title=None, +): + """One state.db ``sessions`` row. Titled by default so the row is visible.""" + return { + "id": sid, + "title": sid if title is None else title, + "model": "gpt-x", + "message_count": messages, + "started_at": started_at, + "source": source, + "project_id": project_id, + "parent_session_id": parent, + "ended_at": ended_at, + "end_reason": end_reason, + "messages": messages, + } + + +def _write_state_db(db_path, rows, *, lineage_columns=True): + """Create a minimal agent state.db from ``_session()`` rows.""" + columns = [ + "id", "title", "model", "message_count", "started_at", "source", "project_id", + ] + lineage_ddl = "" + if lineage_columns: + columns += ["parent_session_id", "ended_at", "end_reason"] + lineage_ddl = ", parent_session_id TEXT, ended_at REAL, end_reason TEXT" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE sessions (" + "id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER, " + f"started_at REAL, source TEXT, project_id TEXT{lineage_ddl})" + ) + conn.execute( + "CREATE TABLE messages (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, timestamp REAL, role TEXT)" + ) + placeholders = ", ".join("?" for _ in columns) + for row in rows: + conn.execute( + f"INSERT INTO sessions ({', '.join(columns)}) VALUES ({placeholders})", + [row.get(column) for column in columns], + ) + for index in range(int(row.get("messages") or 0)): + conn.execute( + "INSERT INTO messages (session_id, timestamp, role) VALUES (?, ?, ?)", + (row["id"], float(row["started_at"]) + 0.5 + index, "user"), + ) + conn.commit() + conn.close() + + +def _lineage(prefix, start_ts, segments, *, project_id=None, tip_project_id=None, + step=10.0, messages=1): + """A compression chain: ``segments`` rows, only the last one still open.""" + rows = [] + for index in range(segments): + last = index == segments - 1 + started_at = start_ts + index * step + rows.append(_session( + f"{prefix}-seg{index}", + started_at, + project_id=( + tip_project_id if last and tip_project_id is not None + else project_id if index == 0 + else None + ), + parent=None if index == 0 else f"{prefix}-seg{index - 1}", + ended_at=None if last else started_at + step / 2, + end_reason=None if last else "compression", + messages=messages, + )) + return rows + + +@pytest.fixture +def fake_hermes_home(tmp_path, monkeypatch): + """Point get_cli_sessions() at a temporary HERMES_HOME + projects.json.""" + home = tmp_path / "hermes" + home.mkdir() + + import api.config as cfg + import api.profiles as profiles + + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None) + # Keep root-profile aliasing hermetic: the real _is_root_profile falls back + # to list_profiles_api(), which shells out to the agent CLI. + monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name == "default") + + projects_file = tmp_path / "projects.json" + monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file) + monkeypatch.setattr(models, "PROJECTS_FILE", projects_file) + monkeypatch.setattr(models, "_projects_migrated", True) + projects_file.write_text("[]", encoding="utf-8") + + models.clear_cli_sessions_cache() + yield home + models.clear_cli_sessions_cache() + + +def _register_projects(tmp_path, *project_ids, profile="default"): + """Write ``project_ids`` into the projects.json the loader resolves against.""" + existing = json.loads((tmp_path / "projects.json").read_text(encoding="utf-8")) + existing.extend( + { + "project_id": project_id, + "name": f"Project {project_id}", + "color": "#6366f1", + "profile": profile, + "created_at": 1.0, + } + for project_id in project_ids + ) + (tmp_path / "projects.json").write_text(json.dumps(existing), encoding="utf-8") + models.clear_cli_sessions_cache() + + +# ── The original bug: an assigned session older than the recent window ──────── + + +def test_project_assigned_cli_session_survives_recent_session_limit( + fake_hermes_home, tmp_path, monkeypatch +): + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-123") + + rows = [ + _session(f"recent-{index:02d}", BASE_TS + 100 + index) + for index in range(25) + ] + rows.append(_session("older-assigned", BASE_TS, project_id="project-123")) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + by_id = {session["session_id"]: session for session in sessions} + + assert "older-assigned" in by_id + assert by_id["older-assigned"]["project_id"] == "project-123" + assert [session["session_id"] for session in sessions].count("older-assigned") == 1 + + +@pytest.mark.parametrize( + ("root_project_id", "tip_project_id"), + [ + ("project-123", None), + (None, "project-123"), + ], +) +def test_project_assigned_compression_lineage_is_returned_once( + fake_hermes_home, + tmp_path, + monkeypatch, + root_project_id, + tip_project_id, +): + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-123") + + rows = [ + _session( + "lineage-root", + BASE_TS, + project_id=root_project_id, + ended_at=BASE_TS + 1, + end_reason="compression", + ), + _session("lineage-tip", BASE_TS + 200, project_id=tip_project_id, parent="lineage-root"), + ] + # Keep the tip inside the normal five-row window while pushing the root + # outside its 8x SQL candidate window. This reproduces the real two-pass + # boundary: the normal pass sees only the tip and the project pass must + # bring enough lineage context to avoid appending the root separately. + rows.extend(_session(f"newer-{index}", BASE_TS + 300 + index) for index in range(4)) + rows.extend(_session(f"middle-{index}", BASE_TS + 100 + index) for index in range(56)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + lineage_rows = [ + session + for session in sessions + if session["session_id"] in {"lineage-root", "lineage-tip"} + ] + + assert len(lineage_rows) == 1 + assert lineage_rows[0]["session_id"] in {"lineage-root", "lineage-tip"} + assert lineage_rows[0]["project_id"] == "project-123" + + +# ── Finding 1: the recovered assigned set must stay bounded ─────────────────── + + +def test_assigned_conversations_are_bounded_per_project( + fake_hermes_home, tmp_path, monkeypatch +): + """250 assigned conversations in one project yield the newest 200, not 250.""" + monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_LIMIT", 200) + _register_projects(tmp_path, "project-a") + + rows = [ + _session(f"assigned-{index:04d}", BASE_TS + index, project_id="project-a") + for index in range(250) + ] + rows.extend( + _session(f"recent-{index:02d}", BASE_TS + 1000 + index) for index in range(25) + ) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + assigned = [s for s in sessions if s["project_id"] == "project-a"] + assigned_ids = {s["session_id"] for s in assigned} + + assert len(assigned) == 200 + # The bound drops the OLDEST assigned conversations, never a newer one. + assert "assigned-0249" in assigned_ids + assert "assigned-0050" in assigned_ids + assert "assigned-0049" not in assigned_ids + assert "assigned-0000" not in assigned_ids + + +def test_route_cap_bounds_imported_sidecar_assigned_rows(fake_hermes_home): + """Imported CLI sidecars bypass every state.db cap (review finding 1). + + ``all_sessions()`` rows never pass through read_importable_agent_session_rows, + so the merged payload is the only place that can bound them. 1,000 assigned + rows used to yield 1,000 returned rows. + """ + sidecars = [ + { + "session_id": f"imported-{index:04d}", + "is_cli_session": True, + "project_id": "project-prime", + } + for index in range(1000) + ] + + kept = routes._cap_recent_cli_sessions(sidecars) + + assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP + assert kept[0]["session_id"] == "imported-0000" + # The recent window still renders normally; only the overflow is chip-only. + visible = [row for row in kept if not row.get("default_hidden")] + assert len(visible) == routes._cli_visible_session_cap() + # Capping never mutates the caller's rows. + assert not any("default_hidden" in row for row in sidecars) + + +def test_route_cap_composes_with_configured_window(fake_hermes_home, monkeypatch): + """The assigned-retention layer rides on the CONFIGURED sidebar window. + + master made the window configurable (HERMES_WEBUI_VISIBLE_SESSION_LIMIT, + #7631); the project-assigned caps must compose with whatever it resolves + to, not with a hard-coded 20. With the window at 5, 8 assigned rows stay + chip-reachable but only the 5 newest render in the recent window. + """ + from api import config as api_config + + monkeypatch.setattr(api_config, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + sidecars = [ + { + "session_id": f"configured-{index:04d}", + "is_cli_session": True, + "project_id": "project-configured", + } + for index in range(8) + ] + + kept = routes._cap_recent_cli_sessions(sidecars) + + # Assigned retention keeps every row chip-reachable past the window. + assert len(kept) == 8 + visible = [row for row in kept if not row.get("default_hidden")] + chip_only = [row for row in kept if row.get("default_hidden")] + assert len(visible) == 5 == routes._cli_visible_session_cap() + # The OLDEST end of the project's history is what goes chip-only. + assert [row["session_id"] for row in chip_only] == [ + "configured-0005", + "configured-0006", + "configured-0007", + ] + + +def _assigned_project_counts(rows): + """project_id -> kept row count, for the assigned rows only.""" + counts: dict[str, int] = {} + for row in rows: + project_id = row.get("project_id") + if project_id: + counts[project_id] = counts.get(project_id, 0) + 1 + return counts + + +def test_route_cap_bounds_the_reviewers_five_project_reproduction(fake_hermes_home): + """The review's literal reproduction: 1,000 assigned rows over 5 projects. + + A PER-PROJECT 200 is not a bound on the payload — every one of the five + projects stayed under its own 200, so 1,000 rows went in and 1,000 came out, + which is exactly what review finding 1 reported. The review asked for "the + existing 200-row assigned cap across the final MERGED CLI set", so the bound + is on the whole assigned payload, not on one project. + """ + project_count = 5 + per_project = 200 + rows = [] + for project in range(project_count): + rows.extend( + { + "session_id": f"project-{project}-{index:04d}", + "is_cli_session": True, + "project_id": f"project-{project}", + } + for index in range(per_project) + ) + assert len(rows) == 1000 + + kept = routes._cap_recent_cli_sessions(rows) + + assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP == 200 + # Bounded by a FAIR draw, not by truncating the head of the list: the rows + # are grouped per project, so a flat `sessions[:200]` would have handed + # project-0 every slot and left the other four unreachable — the starvation + # greptile rejected as P1. The recent window (20 rows) is RESERVED first — + # all of them belong to project-0 here — and the remaining 180-slot budget + # spreads round-robin over the undrawn rows of every project. + counts = _assigned_project_counts(kept) + assert len(counts) == project_count + assert counts["project-0"] == routes._cli_visible_session_cap() + 36 + assert {counts[f"project-{project}"] for project in range(1, project_count)} == {36} + + +def test_route_cap_splits_the_merged_budget_fairly_between_projects(fake_hermes_home): + """A busy project must not evict another project's history (greptile P1). + + The merged budget is drawn round-robin, newest first WITHIN each project, so + two equally deep projects get an equal share and each keeps its newest + conversations rather than an arbitrary slice. + """ + rows = [] + for project in ("project-a", "project-b"): + rows.extend( + { + "session_id": f"{project}-{index:04d}", + "is_cli_session": True, + "project_id": project, + } + for index in range(250) + ) + + kept = routes._cap_recent_cli_sessions(rows) + + # 20 of the 200 assigned rows are project-a's recent window, RESERVED + # before the draw; the remaining 180-slot budget spreads round-robin over + # the undrawn rows, so project-a adds 90 and project-b gets 90. + half = (routes.CLI_PROJECT_ASSIGNED_CAP - routes._cli_visible_session_cap()) // 2 + assert _assigned_project_counts(kept) == { + "project-a": routes._cli_visible_session_cap() + half, + "project-b": half, + } + assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP + # Newest first within each project: the draw keeps the head of each queue. + kept_ids = {row["session_id"] for row in kept} + for project in ("project-a", "project-b"): + assert f"{project}-0000" in kept_ids + assert "project-a-0019" in kept_ids and "project-a-0020" in kept_ids + assert f"project-a-{routes._cli_visible_session_cap() + half - 1:04d}" in kept_ids + assert f"project-a-{routes._cli_visible_session_cap() + half:04d}" not in kept_ids + assert f"project-b-{half - 1:04d}" in kept_ids + assert f"project-b-{half:04d}" not in kept_ids + + +def test_route_cap_shrinks_every_share_instead_of_dropping_projects(fake_hermes_home): + """Every project with an assigned session keeps at least one row. + + 8,000 assigned rows over 40 projects still leaves every chip reachable: the + draw shrinks each project's slice to 200 // 40 = 5 rather than dropping whole + projects, which is the starvation greptile rejected as P1. + """ + project_count = 40 + rows = [] + for project in range(project_count): + rows.extend( + { + "session_id": f"project-{project:02d}-{index:04d}", + "is_cli_session": True, + "project_id": f"project-{project:02d}", + } + for index in range(200) + ) + + kept = routes._cap_recent_cli_sessions(rows) + + counts = _assigned_project_counts(kept) + assert len(counts) == project_count + assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP + assert min(counts.values()) >= 4 + assert set(counts.values()) <= {4, 5, routes._cli_visible_session_cap() + 5} + # project-00's newest rows own the reserved recent window, on top of its + # equal share of the remaining budget. + assert counts["project-00"] == routes._cli_visible_session_cap() + 5 + + +def test_route_cap_never_drops_the_recent_window_the_base_shows(fake_hermes_home): + """The 2026-09-24 re-gate reproduction: the fair draw must not remove a + row the base displays. + + 11 projects x 20 sessions, with ALL 20 newest conversations in one project. + Spending the whole assigned budget round-robin before the recent window is + applied hands that project only ~19 of 200 slots; the keep loop then drops + its undrawn newest rows, and sessions visible on the default list today + appear in neither the default payload nor behind their project chip. + + The fix reserves the rows the recent window itself would show (the first + ``cli_cap`` CLI rows in sort order, assigned or not) before the fair draw + spends only the remaining budget across starved projects. Assert all 20 + newest stay in the payload, and that the draw still starves nothing else. + """ + project_count = 11 + rows = [] + # The 20 newest sessions, all assigned to project-00 (newest-first list). + rows.extend( + { + "session_id": f"project-00-recent-{index:02d}", + "is_cli_session": True, + "project_id": "project-00", + } + for index in range(20) + ) + # Then 20 older rows per remaining project: 11 projects x 20 sessions, the + # review's literal reproduction. + for project in range(1, project_count): + rows.extend( + { + "session_id": f"project-{project:02d}-{index:03d}", + "is_cli_session": True, + "project_id": f"project-{project:02d}", + } + for index in range(20) + ) + + kept = routes._cap_recent_cli_sessions(rows) + + kept_ids = {row["session_id"] for row in kept} + missing = [f"project-00-recent-{index:02d}" for index in range(20) + if f"project-00-recent-{index:02d}" not in kept_ids] + assert not missing, f"the base shows these rows; the draw dropped {missing}" + counts = _assigned_project_counts(kept) + assert counts["project-00"] == 20 + # The reservation consumed the recent window's share of the budget; the + # remaining assigned budget still reaches the other projects. + assert min(counts[f"project-{project:02d}"] for project in range(1, project_count)) >= 1 + assert len(kept) <= routes.CLI_PROJECT_ASSIGNED_CAP + + +def test_route_cap_reservation_spends_the_draw_budget_not_adds_to_it(fake_hermes_home): + """The reserved recent rows count AGAINST the assigned cap, not on top. + + A cli_cap-sized recent window fully assigned to one project must leave the + round-robin with nothing to spend: total assigned rows in the payload stay + at the cap, never cap + draw. + """ + rows = [ + { + "session_id": f"assigned-{index:03d}", + "is_cli_session": True, + "project_id": "solo", + } + for index in range(routes.CLI_PROJECT_ASSIGNED_CAP) + ] + + kept = routes._cap_recent_cli_sessions(rows, cli_cap=20) + + # The 20 newest rows own the recent window (reserved, paid by it); the + # draw keeps the rest of this project's history chip-reachable up to the + # assigned cap — 200 assigned rows in total, only the window rendering. + assigned = [row for row in kept if row.get("project_id")] + assert len(assigned) == routes.CLI_PROJECT_ASSIGNED_CAP + visible = [row for row in assigned if not row.get("default_hidden")] + assert [row["session_id"] for row in visible] == [ + f"assigned-{index:03d}" for index in range(20) + ] + assert all(row.get("default_hidden") for row in assigned[20:]) + + +def test_route_cap_bound_wins_when_projects_outnumber_slots(fake_hermes_home): + """The documented edge of the draw: 200 rows cannot represent 250 projects. + + Deliberate and pinned so it is not mistaken for a bug: past + ``CLI_PROJECT_ASSIGNED_CAP`` assigned projects the review's number is the + hard constraint, so the most recently active ``CLI_PROJECT_ASSIGNED_CAP`` + projects get one row each. The draw still never gives one project two rows + while another has none. + """ + project_count = 250 + rows = [] + for project in range(project_count): + rows.extend( + { + "session_id": f"project-{project:03d}-{index}", + "is_cli_session": True, + "project_id": f"project-{project:03d}", + } + for index in range(3) + ) + + kept = routes._cap_recent_cli_sessions(rows) + + counts = _assigned_project_counts(kept) + assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP + # The recent window is reserved for the newest rows (project-000 keeps all + # 3 of its rows); the remaining budget still reaches one row per project — + # the draw never gives a project a second row while another has none. + assert counts["project-000"] == 3 + assert set(counts.values()) <= {1, 2, 3} + assert sum(n for p, n in counts.items() if p != "project-000") == routes.CLI_PROJECT_ASSIGNED_CAP - 3 + assert len(counts) == 186 + # The projects that keep a row are the ones with the most recent activity, + # i.e. the head of the newest-first merged list. + assert set(counts.keys()) <= { + f"project-{project:03d}" for project in range(routes.CLI_PROJECT_ASSIGNED_CAP) + } + + +def test_route_cap_keeps_every_assigned_row_under_the_merged_cap(fake_hermes_home): + """Negative control: the draw is inert while the assigned set fits. + + Without this, "bounded at 200" could just as well be a blanket shrink. Two + projects with 60 conversations each keep all 120. + """ + rows = [] + for project in ("project-a", "project-b"): + rows.extend( + { + "session_id": f"{project}-{index:04d}", + "is_cli_session": True, + "project_id": project, + } + for index in range(60) + ) + + kept = routes._cap_recent_cli_sessions(rows) + + assert _assigned_project_counts(kept) == {"project-a": 60, "project-b": 60} + assert len(kept) == 120 < routes.CLI_PROJECT_ASSIGNED_CAP + + +def test_route_merged_assigned_cap_is_the_existing_model_row_cap(): + """The route's merged bound must stay the 200-row cap the review named. + + ``api.models`` is imported below this cap in ``api/routes.py``, so the value + is spelled as a literal there and only a test can keep the two honest. The + merged bound must also never exceed what the model recovery pass can deliver, + or the route would advertise a budget nothing can fill. + """ + assert routes.CLI_PROJECT_ASSIGNED_CAP == models.PROJECT_ASSIGNED_CLI_LIMIT == 200 + assert ( + routes.CLI_PROJECT_ASSIGNED_CAP + <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING + ) + + +def test_more_than_two_hundred_assigned_conversations_survive_across_projects( + fake_hermes_home, tmp_path +): + """The model pass budgets per project too, not a single global 200.""" + _register_projects(tmp_path, "project-a", "project-b") + + rows = [ + _session(f"a-{index:04d}", BASE_TS + index, project_id="project-a") + for index in range(150) + ] + rows.extend( + _session(f"b-{index:04d}", BASE_TS + 500 + index, project_id="project-b") + for index in range(150) + ) + rows.extend( + _session(f"recent-{index:02d}", BASE_TS + 2000 + index) for index in range(25) + ) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + counts: dict[str, int] = {} + for session in sessions: + if session["project_id"]: + counts[session["project_id"]] = counts.get(session["project_id"], 0) + 1 + + # 300 assigned conversations total — a global 200 bound would silently drop + # the 100 oldest, all of them project-a's. + assert counts == {"project-a": 150, "project-b": 150} + + +def test_model_bounds_a_skewed_project_inside_a_wider_scan_window( + fake_hermes_home, tmp_path +): + """The per-project budget, not the query LIMIT, is what bounds a project. + + The recovery query asks for ``PROJECT_ASSIGNED_CLI_LIMIT * len(projects)`` + conversations, so with two registered projects its window is 400 — wide + enough to hand back all 250 of project-a's on its own. Only the per-project + accounting keeps project-a at 200, so a skewed distribution is the case that + actually exercises it (a single-project profile has query_limit == the bound + and would pass either way). + """ + _register_projects(tmp_path, "project-a", "project-b") + + rows = [ + _session(f"a-{index:04d}", BASE_TS + index, project_id="project-a") + for index in range(250) + ] + rows.extend( + _session(f"b-{index:04d}", BASE_TS + 5000 + index, project_id="project-b") + for index in range(5) + ) + rows.extend( + _session(f"recent-{index:02d}", BASE_TS + 9000 + index) for index in range(25) + ) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + counts: dict[str, int] = {} + for session in sessions: + if session["project_id"]: + counts[session["project_id"]] = counts.get(session["project_id"], 0) + 1 + assigned_ids = {s["session_id"] for s in sessions if s["project_id"] == "project-a"} + + assert counts == {"project-a": models.PROJECT_ASSIGNED_CLI_LIMIT, "project-b": 5} + # The budget spends newest-first, so the dropped rows are the oldest 50. + assert "a-0249" in assigned_ids + assert "a-0050" in assigned_ids + assert "a-0049" not in assigned_ids + # The smaller project is untouched by its neighbour's overflow. + assert {s["session_id"] for s in sessions if s["project_id"] == "project-b"} == { + f"b-{index:04d}" for index in range(5) + } + + +# ── greptile P1: a busy project must not starve the quieter ones ────────────── + + +def _assigned_counts(sessions): + counts: dict[str, int] = {} + for session in sessions: + if session["project_id"]: + counts[session["project_id"]] = counts.get(session["project_id"], 0) + 1 + return counts + + +def test_busy_project_does_not_starve_quieter_projects( + fake_hermes_home, tmp_path, monkeypatch +): + """The per-project BUDGET is not enough while the QUERY is one global window. + + The recovery pass asked for ``limit * len(projects)`` newest assigned + conversations in a single recency-ordered query and only then applied the + per-project budget. When one project owns that whole window, every other + project's assigned conversations are never even considered: their chips show + nothing at all. Reproduced with a scaled budget (3) so the fixture stays + small; ``test_busy_project_starvation_at_production_limits`` pins the same + behaviour at the real 200. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-busy", "project-quiet-a", "project-quiet-b") + + rows = [ + _session("quiet-a-old", BASE_TS, project_id="project-quiet-a"), + _session("quiet-b-old", BASE_TS + 1, project_id="project-quiet-b"), + ] + # 12 newer assigned conversations in one project fill the 3 * 3 = 9 window. + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 100 + index, project_id="project-busy") + for index in range(12) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=3, + ) + + # Was {'project-busy': 3}: both quiet projects were completely absent. + assert _assigned_counts(sessions) == { + "project-busy": 3, + "project-quiet-a": 1, + "project-quiet-b": 1, + } + # The busy project still gets its newest, and is still bounded. + assert {s["session_id"] for s in sessions if s["project_id"] == "project-busy"} == { + "busy-011", "busy-010", "busy-009", + } + + +def test_busy_project_starvation_at_production_limits(fake_hermes_home, tmp_path): + """The same starvation with the real PROJECT_ASSIGNED_CLI_LIMIT (200). + + Two registered projects give the old global query a 400-conversation window; + 400 newer conversations in one project consumed all of it, so the quiet + project's single assigned conversation was unreachable. + """ + _register_projects(tmp_path, "project-busy", "project-quiet") + + rows = [_session("quiet-old", BASE_TS, project_id="project-quiet")] + rows.extend( + _session(f"busy-{index:04d}", BASE_TS + 100 + index, project_id="project-busy") + for index in range(models.PROJECT_ASSIGNED_CLI_LIMIT * 2) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 5000 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + counts = _assigned_counts(models.get_cli_sessions()) + + # Was {'project-busy': 200}. + assert counts == { + "project-busy": models.PROJECT_ASSIGNED_CLI_LIMIT, + "project-quiet": 1, + } + + +def test_starved_project_recovers_its_whole_compression_lineage( + fake_hermes_home, tmp_path, monkeypatch +): + """The project-scoped follow-up query is still lineage-keyed. + + ``project_ids`` narrows only the recursive CTE's SEED, so a starved project's + conversation still arrives as ONE row even when its assignment sits on the + root of a compression chain. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-busy", "project-quiet") + + rows = _lineage("quiet-chain", BASE_TS, 3, project_id="project-quiet") + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 100 + index, project_id="project-busy") + for index in range(12) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=2, + ) + + quiet = [s for s in sessions if s["project_id"] == "project-quiet"] + assert len(quiet) == 1, [s["session_id"] for s in quiet] + assert quiet[0]["session_id"] in {f"quiet-chain-seg{index}" for index in range(3)} + + +def test_no_followup_queries_when_the_global_window_is_not_saturated( + fake_hermes_home, tmp_path, monkeypatch +): + """A short global window already saw everything, so it must cost 1 query. + + The starvation follow-up is gated on saturation. Without that gate every + sidebar build on every profile with projects would pay a GROUP BY probe plus + a query per project. + """ + _register_projects(tmp_path, "project-a", "project-b", "project-c") + + rows = [ + _session("a-1", BASE_TS, project_id="project-a"), + _session("b-1", BASE_TS + 1, project_id="project-b"), + ] + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + assigned_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_assignment") == "assigned": + assigned_queries.append(kwargs.get("project_ids")) + return real_reader(*args, **kwargs) + + probes = [] + real_probe = models.read_assigned_project_row_counts + + def _counting_probe(*args, **kwargs): + probes.append(args) + return real_probe(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + monkeypatch.setattr(models, "read_assigned_project_row_counts", _counting_probe) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=3, + ) + + assert _assigned_counts(sessions) == {"project-a": 1, "project-b": 1} + assert assigned_queries == [None], "one global assigned query, no per-project ones" + assert probes == [], "the GROUP BY probe is only paid on a saturated window" + + +def test_short_global_projection_still_refills_a_starved_project( + fake_hermes_home, tmp_path, monkeypatch +): + """A fully consumed raw window can project short without exhausting state.db. + + Three 64-segment busy lineages fill the final ``6 * 32`` raw candidate + window for a two-project, three-conversation budget. They collapse to only + three logical conversations, while an older quiet-project conversation sits + just beyond that raw window. A gate based only on ``len(global_rows)`` + mistakes that projection shortfall for a small database and leaves the quiet + project unreachable. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 4) + _register_projects(tmp_path, "project-busy", "project-quiet") + per_project_limit = 3 + global_limit = per_project_limit * 2 + raw_window = global_limit * max(agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS) + assert raw_window % 3 == 0 + + rows = [_session("quiet-old", BASE_TS, project_id="project-quiet")] + for index in range(3): + rows.extend(_lineage( + f"busy-chain-{index}", + BASE_TS + 10_000 + index * 1_000, + raw_window // 3, + project_id="project-busy", + step=1.0, + )) + _write_state_db(fake_hermes_home / "state.db", rows) + + scoped_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_ids"): + scoped_queries.append((kwargs["project_ids"][0], kwargs["limit"])) + return real_reader(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=per_project_limit, + ) + + assert "quiet-old" in {session["session_id"] for session in sessions} + assert scoped_queries == [("project-quiet", per_project_limit)] + + +def test_scoped_recovery_widens_when_its_own_raw_window_is_exhausted( + fake_hermes_home, tmp_path, monkeypatch +): + """A starved project's OWN raw window can be consumed, not just the global one. + + The global window's exhaustion is what triggers the project-scoped + follow-up, but that scoped query carries a raw candidate window of its own + (``limit * 8``, re-widened to ``limit * 32`` by the shared reader). A + lineage-heavy project fills it with compression segments, so the query + collapses them to one logical conversation and comes back short of + ``remaining`` — while an older assigned conversation of the SAME project + sits just beyond that window. Without the exhaustion signal the loop + advances to the next project and leaves this one under-delivered on every + rebuild, so the conversation behind the window is never recovered. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 20) + _register_projects(tmp_path, "project-busy", "project-quiet") + per_project_limit = 4 + # The pass's own scan budget for two projects under a ceiling of 20. + scoped_scan_budget = min(per_project_limit * 2, 20) + + # The starved project's remaining budget is the full per-project limit, and + # its scoped raw windows — 4 * 8, then 4 * 32 — are filled by its own + # compression segments, which collapse to a single logical conversation. + scoped_raw_window = per_project_limit * max(agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS) + rows = [_session("quiet-old", BASE_TS, project_id="project-quiet")] + rows.extend(_lineage( + "quiet-chain", + BASE_TS + 10_000, + scoped_raw_window + 100, + project_id="project-quiet", + step=1.0, + )) + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 1000 + index, project_id="project-busy") + for index in range(25) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 9000 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + scoped_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_ids"): + scoped_queries.append(( + kwargs["project_ids"][0], + kwargs.get("limit"), + kwargs.get("return_window_exhaustion"), + )) + return real_reader(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=per_project_limit, + ) + + quiet = { + session["session_id"] + for session in sessions + if session["project_id"] == "project-quiet" + } + assert "quiet-old" in quiet, "the conversation behind the scoped raw window is lost" + + # Every scoped query asks for the exhaustion signal, the binding window + # buys exactly ONE wider re-query, and that re-query stays inside the + # pass's own scan budget. + assert scoped_queries == [ + ("project-quiet", per_project_limit, True), + ("project-quiet", scoped_scan_budget, True), + ], "one retry at the pass's scan budget, not a widening loop" + assert all( + limit <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING + for _, limit, _ in scoped_queries + ) + + +def test_saturated_window_pays_one_query_per_starved_project( + fake_hermes_home, tmp_path, monkeypatch +): + """Pin the cost of the worst case: probe + one query per starved project. + + The ceiling is forced to 20 over 5 projects (a share of 4) so saturation is + reachable with a small fixture. Every project ends up represented, the + follow-up queries are project-scoped and each carries only that project's + remaining budget, and the recovered total stays under the ceiling. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 20) + quiet = [f"quiet-{index}" for index in range(4)] + _register_projects(tmp_path, "project-busy", *quiet) + + rows = [ + _session(f"{project}-old", BASE_TS + index, project_id=project) + for index, project in enumerate(quiet) + ] + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 1000 + index, project_id="project-busy") + for index in range(25) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 9000 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + scoped_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_ids"): + scoped_queries.append((kwargs["project_ids"], kwargs.get("limit"))) + return real_reader(*args, **kwargs) + + probes = [] + real_probe = models.read_assigned_project_row_counts + + def _counting_probe(*args, **kwargs): + probes.append(args) + return real_probe(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + monkeypatch.setattr(models, "read_assigned_project_row_counts", _counting_probe) + + counts = _assigned_counts(models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + )) + + assert len(probes) == 1, "exactly one GROUP BY probe per saturated build" + assert scoped_queries == [(("quiet-0",), 4), (("quiet-1",), 4), + (("quiet-2",), 4), (("quiet-3",), 4)] + assert counts == {"project-busy": 4, "quiet-0": 1, "quiet-1": 1, + "quiet-2": 1, "quiet-3": 1} + assert sum(counts.values()) <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING + + +def test_followup_serves_every_starved_project_neediest_first( + fake_hermes_home, tmp_path, monkeypatch +): + """Every starved project gets its follow-up, in neediest-first order. + + The first version of the follow-up was capped at a fixed number of projects + per build; with the cap forced to 1, only the lower-numbered project id was + served and the other stayed unreachable on every rebuild (greptile P1 on + #6659). The cap is gone: both starved projects are served, in the same + deterministic order the cap used to cut. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-busy", "project-quiet-a", "project-quiet-b") + + rows = [ + _session("quiet-a-old", BASE_TS, project_id="project-quiet-a"), + _session("quiet-b-old", BASE_TS + 1, project_id="project-quiet-b"), + ] + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 100 + index, project_id="project-busy") + for index in range(12) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + scoped_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_ids"): + scoped_queries.append((kwargs["project_ids"][0], kwargs.get("limit"))) + return real_reader(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=3, + ) + + # Neediest first: both projects are equally starved, so id order wins. + assert scoped_queries == [("project-quiet-a", 3), ("project-quiet-b", 3)] + assert _assigned_counts(sessions) == { + "project-busy": 3, + "project-quiet-a": 1, + "project-quiet-b": 1, + } + + +def test_every_starved_project_is_served_past_the_old_refill_cap( + fake_hermes_home, tmp_path, monkeypatch +): + """The refill has no project-count cap: all 39 starved projects are served. + + greptile P1 on #6659: with more starved projects than the old fixed refill + cap (32), only the first 32 were served and every later project stayed + unreachable on each rebuild — the recovery is re-derived from the database + on every build, so the same 32 won every time. Ceiling 40 over 40 projects + is a share of 1, one busy project saturates the global window, and all 39 + quieter projects must still get their scoped follow-up. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 40) + quiet = [f"quiet-{index:02d}" for index in range(39)] + _register_projects(tmp_path, "project-busy", *quiet) + + rows = [ + _session(f"{project}-old", BASE_TS + index, project_id=project) + for index, project in enumerate(quiet) + ] + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 1000 + index, project_id="project-busy") + for index in range(40) + ) + _write_state_db(fake_hermes_home / "state.db", rows) + + scoped_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_ids"): + scoped_queries.append(kwargs["project_ids"][0]) + return real_reader(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + ) + + counts = _assigned_counts(sessions) + assert set(counts) == {"project-busy", *quiet} + assert len(scoped_queries) == len(quiet) + assert set(scoped_queries) == set(quiet) + assert counts == {"project-busy": 5, **{project: 1 for project in quiet}} + + +def test_scan_ceiling_is_shared_equally_between_projects( + fake_hermes_home, tmp_path, monkeypatch +): + """Past the ceiling every project's share shrinks; none is left with zero. + + Ceiling 6 over 3 projects is a share of 2 even though the requested budget is + 5, so the recovered payload stays under the ceiling AND every project is + represented — the old code let the newest project take 5 of the 6. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 6) + _register_projects(tmp_path, "project-a", "project-b", "project-c") + + rows = [] + for offset, project in enumerate(("project-a", "project-b", "project-c")): + rows.extend( + _session( + f"{project}-{index}", + BASE_TS + offset * 100 + index, + project_id=project, + ) + for index in range(4) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 900 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=5, + ) + + counts = _assigned_counts(sessions) + assert counts == {"project-a": 2, "project-b": 2, "project-c": 2} + assert sum(counts.values()) <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING + + +# ── The reader-level contract the per-project follow-up relies on ───────────── + + +def test_project_ids_narrows_the_assigned_query_to_one_project(tmp_path): + """``project_ids`` seeds the lineage CTE with one project's rows only.""" + db_path = tmp_path / "state.db" + rows = [ + _session("a-1", BASE_TS, project_id="project-a"), + _session("b-1", BASE_TS + 1, project_id="project-b"), + _session("plain", BASE_TS + 2), + ] + rows.extend(_lineage("b-chain", BASE_TS + 10, 3, project_id="project-b")) + _write_state_db(db_path, rows) + + def _ids(**kwargs): + return sorted( + row["id"] + for row in agent_sessions.read_importable_agent_session_rows( + db_path, limit=50, exclude_sources=None, **kwargs + ) + ) + + assert _ids(project_assignment="assigned") == ["a-1", "b-1", "b-chain-seg2"] + assert _ids(project_assignment="assigned", project_ids=("project-b",)) == [ + "b-1", "b-chain-seg2", + ] + assert _ids(project_assignment="assigned", project_ids=("project-a",)) == ["a-1"] + # Unknown ids are not an error, they simply select nothing. + assert _ids(project_assignment="assigned", project_ids=("nope",)) == [] + + +def test_project_ids_requires_the_assigned_filter(tmp_path): + """A silently unnarrowed query is exactly the starvable one — refuse it.""" + db_path = tmp_path / "state.db" + _write_state_db(db_path, [_session("a-1", BASE_TS, project_id="project-a")]) + + for assignment in (None, "unassigned"): + with pytest.raises(ValueError, match="project_ids requires"): + agent_sessions.read_importable_agent_session_rows( + db_path, project_assignment=assignment, project_ids=("project-a",) + ) + + +def test_empty_project_ids_selects_nothing(tmp_path): + """``project_ids=()`` must not degrade to "every assigned conversation".""" + db_path = tmp_path / "state.db" + _write_state_db(db_path, [_session("a-1", BASE_TS, project_id="project-a")]) + + assert agent_sessions.read_importable_agent_session_rows( + db_path, exclude_sources=None, project_assignment="assigned", project_ids=() + ) == [] + + +def test_project_ids_narrows_on_a_schema_without_lineage_columns(tmp_path): + """The no-parent_session_id schema takes the plain ``project_id IN`` path.""" + db_path = tmp_path / "state.db" + _write_state_db( + db_path, + [ + _session("a-1", BASE_TS, project_id="project-a"), + _session("b-1", BASE_TS + 1, project_id="project-b"), + ], + lineage_columns=False, + ) + + rows = agent_sessions.read_importable_agent_session_rows( + db_path, + limit=50, + exclude_sources=None, + project_assignment="assigned", + project_ids=("project-b",), + ) + + assert [row["id"] for row in rows] == ["b-1"] + + +def test_assigned_project_row_counts_probe(tmp_path): + """The probe counts assigned RAW rows per project and honours exclusions.""" + db_path = tmp_path / "state.db" + rows = [ + _session("a-1", BASE_TS, project_id="project-a"), + _session("a-2", BASE_TS + 1, project_id="project-a"), + _session("b-1", BASE_TS + 2, project_id="project-b"), + _session("b-cron", BASE_TS + 3, project_id="project-b", source="cron"), + _session("plain", BASE_TS + 4), + _session("blank", BASE_TS + 5, project_id=" "), + ] + _write_state_db(db_path, rows) + + assert agent_sessions.read_assigned_project_row_counts(db_path) == { + "project-a": 2, "project-b": 2, + } + assert agent_sessions.read_assigned_project_row_counts( + db_path, exclude_sources=("cron", "webhook", "kanban") + ) == {"project-a": 2, "project-b": 1} + # Missing db and a schema without project_id are answered, not raised. + assert agent_sessions.read_assigned_project_row_counts(tmp_path / "nope.db") == {} + + +def test_assigned_project_row_counts_probe_on_schema_without_project_id(tmp_path): + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(str(db_path)) + conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT)") + conn.commit() + conn.close() + + assert agent_sessions.read_assigned_project_row_counts(db_path) == {} + + +# ── Finding 2: assigned rows must not spend the unassigned quota ────────────── + + +def test_route_cap_keeps_full_unassigned_window_when_assigned_rows_lead( + fake_hermes_home, +): + """3 assigned + 20 unassigned used to yield only 17 unassigned.""" + rows = [ + { + "session_id": f"assigned-{index}", + "is_cli_session": True, + "project_id": "project-prime", + } + for index in range(3) + ] + rows.extend( + {"session_id": f"unassigned-{index:02d}", "is_cli_session": True, "project_id": None} + for index in range(20) + ) + + kept = routes._cap_recent_cli_sessions(rows) + unassigned_kept = [row for row in kept if not row.get("project_id")] + + assert len(unassigned_kept) == 20 + assert len(kept) == 23 + # All three assigned rows are inside the recent window here, so none of them + # is demoted to a chip-only row. + assert not any(row.get("default_hidden") for row in kept) + + +def test_route_cap_preserves_project_assigned_overflow_as_hidden(fake_hermes_home): + recent = [ + {"session_id": f"recent-{index}", "is_cli_session": True, "project_id": None} + for index in range(20) + ] + assigned_overflow = { + "session_id": "assigned-overflow", + "is_cli_session": True, + "project_id": "project-prime", + } + unassigned_overflow = { + "session_id": "unassigned-overflow", + "is_cli_session": True, + "project_id": None, + } + + kept = routes._cap_recent_cli_sessions( + recent + [assigned_overflow, unassigned_overflow], + cli_cap=20, + ) + + by_id = {row["session_id"]: row for row in kept} + assert len(kept) == 21 + assert "unassigned-overflow" not in by_id + assert by_id["assigned-overflow"]["default_hidden"] is True + assert "default_hidden" not in assigned_overflow + + +def test_model_refills_the_unassigned_window_consumed_by_assigned_rows( + fake_hermes_home, tmp_path +): + """The first state.db pass fetches 20 rows TOTAL, so it must be refilled.""" + _register_projects(tmp_path, "project-a") + + rows = [ + _session(f"assigned-{index}", BASE_TS + 1000 + index, project_id="project-a") + for index in range(3) + ] + rows.extend(_session(f"plain-{index:02d}", BASE_TS + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + unassigned = [s for s in sessions if not s["project_id"]] + + assert len(unassigned) == models.CLI_VISIBLE_SESSION_LIMIT == 20 + assert len([s for s in sessions if s["project_id"] == "project-a"]) == 3 + + +def _write_webui_sidecar(session_dir, sid, *, project_id, updated_at): + """A WebUI session sidecar carrying a project assignment. + + This is what ``/api/session/move`` actually writes: it sets ``s.project_id`` + and saves the sidecar. NOTHING writes ``state.db.sessions.project_id`` — + ``grep 'UPDATE sessions'`` only ever touches title / message_count / + parent_session_id — so the assignment exists only here. + """ + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / f"{sid}.json").write_text( + json.dumps({ + "session_id": sid, + "title": sid, + "created_at": updated_at, + "updated_at": updated_at, + "message_count": 1, + "project_id": project_id, + "messages": [], + "tool_calls": [], + }), + encoding="utf-8", + ) + models.clear_sidecar_metadata_cache() + models.clear_cli_sessions_cache() + + +def test_sidecar_carried_assignment_does_not_spend_an_unassigned_slot( + fake_hermes_home, tmp_path, monkeypatch +): + """A WebUI-side move must not shorten the unassigned sidebar window. + + ``/api/session/move`` records the assignment on the WebUI sidecar ONLY, so + the model's unassigned counter — which read ``state.db.project_id`` — saw all + 23 conversations as unassigned and stopped at its 20-row window. The route + then reclassified the 3 moved conversations as assigned (``all_sessions()`` + re-surfaces the sidecar's ``project_id`` and the state row is dropped as + already represented), leaving only 17 unassigned CLI rows in the payload and + never fetching cli-00 / cli-01 / cli-02 at all — the reviewer's exact 3 + 20 + -> 17 number, in the guarantee this file's docstring claims to pin. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + rows = [_session(f"cli-{index:02d}", BASE_TS + index) for index in range(23)] + _write_state_db(fake_hermes_home / "state.db", rows) + + # The three NEWEST conversations were moved into a project from the WebUI. + moved = {"cli-22", "cli-21", "cli-20"} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + sessions = models.get_cli_sessions() + + # The real route pipeline: the moved conversations come back as WebUI sidecar + # rows and their state.db projections are dropped as duplicates. + deduped = routes._dedupe_cli_sidebar_sessions_for_api(sessions, set(moved)) + webui_rows = [ + { + "session_id": sid, + "is_cli_session": True, + "project_id": "project-a", + "updated_at": BASE_TS + int(sid.split("-")[1]), + } + for sid in sorted(moved) + ] + merged = sorted( + webui_rows + deduped, key=lambda s: s.get("updated_at") or 0, reverse=True + ) + kept = routes._cap_recent_cli_sessions(merged) + + unassigned_cli = [ + row for row in kept if row.get("is_cli_session") and not row.get("project_id") + ] + assert len(unassigned_cli) == routes._cli_visible_session_cap() == 20 + # The three genuinely unassigned conversations at the bottom of state.db are + # what the lost slots cost: they were never fetched. + kept_ids = {row["session_id"] for row in kept} + assert {"cli-00", "cli-01", "cli-02"} <= kept_ids + + # ...and the model's own notion of "unassigned" agrees with the + # classification the route applied, which is what made the refill reachable. + assert sum(1 for s in sessions if not s["project_id"]) == 20 + assert {s["session_id"] for s in sessions if s["project_id"] == "project-a"} == moved + + +def _sidebar_rows_after_route(sessions, moved, *, project_id="project-a"): + """Drive the real sidebar chain for WebUI-moved conversations. + + ``/api/sessions`` merges the WebUI store's own rows (which carry the sidecar + ``project_id``) with the state.db projection, drops the state rows the WebUI + rows already represent, and then caps. Reproducing that here is what makes + these tests measure the payload the user actually sees rather than the + model's intermediate list. + """ + deduped = routes._dedupe_cli_sidebar_sessions_for_api(sessions, set(moved)) + # The WebUI row for a moved conversation carries the sidecar's own + # timestamp, which is the one the state projection reports for it too. + activity = {s["session_id"]: s.get("updated_at") for s in sessions} + webui_rows = [ + { + "session_id": sid, + "is_cli_session": True, + "project_id": project_id, + "updated_at": activity.get(sid) or BASE_TS, + } + for sid in sorted(moved) + ] + merged = sorted( + webui_rows + deduped, key=lambda s: s.get("updated_at") or 0, reverse=True + ) + return routes._cap_recent_cli_sessions(merged) + + +def _unassigned_cli_ids(kept): + return [ + row["session_id"] + for row in kept + if row.get("is_cli_session") and not row.get("project_id") + ] + + +@pytest.mark.parametrize( + "total, moved_indexes, expected_unassigned_tail", + [ + # (a) Three moves at the BOTTOM edge of the 20-row window (the window is + # cli-29..cli-10, so only cli-10 is inside it). The single pre-computed + # allowance saw ONE sidecar-only assignment, widened the refill to 21 + # rows, and that 21st row (cli-09) turned out to be moved as well: 19 + # unassigned rows in the payload and cli-07 never fetched at all. + (30, (8, 9, 10), "cli-07"), + # (b) Eleven moves straddling the window boundary (cli-25..cli-20 are + # inside it, cli-19..cli-15 are not): the refill widened by 6, fetched + # 26 rows, and 11 of them were moved -> 15 unassigned rows, with + # cli-09..cli-13 never fetched. + (40, tuple(range(15, 26)), "cli-09"), + ], + ids=["bottom-edge-of-the-window", "straddling-the-boundary"], +) +def test_sidecar_moves_below_the_window_still_deliver_the_full_window( + fake_hermes_home, tmp_path, monkeypatch, total, moved_indexes, + expected_unassigned_tail, +): + """The unassigned window survives moves the FIRST refill could not see. + + ``sidecar_only_assigned`` was computed once, from the pre-refill window, so + any conversation the refill itself brought back carrying a sidecar-only + assignment spent an unassigned slot again — the very failure the widening + exists to remove. Both arrangements below put moved conversations at or below + the window's bottom edge, where the first count cannot possibly know about + them. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + rows = [_session(f"cli-{index:02d}", BASE_TS + index) for index in range(total)] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {f"cli-{index:02d}" for index in moved_indexes} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + sessions = models.get_cli_sessions() + + # The model has to FETCH the conversations the moved rows displaced; a cap + # applied to rows that were never read cannot invent them. + model_ids = {s["session_id"] for s in sessions} + assert expected_unassigned_tail in model_ids + # At LEAST the window: every widening estimates the depth it needs from the + # rows in hand, so it over-reads whenever the moved conversations are denser + # deeper in the history than that. How MUCH it can over-read is not "a few": + # a shape that keeps the window short through all three estimates reaches the + # forced ceiling query and the model then carries the whole band, up to + # UNASSIGNED_CLI_REFILL_SCAN_CEILING (200) rows to deliver 20. The sidebar cap + # below trims the surplus, but only after it has been read and stat()ed. The + # two cost pins further down hold the ordinary shapes off that path. + assert sum(1 for s in sessions if not s["project_id"]) >= 20 + assert {s["session_id"] for s in sessions if s["project_id"] == "project-a"} == moved + + kept = _sidebar_rows_after_route(sessions, moved) + unassigned_cli = _unassigned_cli_ids(kept) + assert len(unassigned_cli) == routes._cli_visible_session_cap() == 20 + # Newest-first, skipping every moved conversation: the 20th unassigned + # conversation is exactly the tail row above. + expected = [ + f"cli-{index:02d}" + for index in range(total - 1, -1, -1) + if index not in set(moved_indexes) + ][:20] + assert unassigned_cli == expected + assert expected[-1] == expected_unassigned_tail + + +def test_refill_follows_recency_not_session_id_order( + fake_hermes_home, tmp_path, monkeypatch +): + """The widening keys on RECENCY, which ids here deliberately contradict. + + ``cli-00`` is the NEWEST conversation, so the window is cli-00..cli-19 and + the moved conversations at its bottom edge are cli-19/cli-20/cli-21. A loop + that reasoned about anything but the recency order the queries use would + widen in the wrong direction and miss cli-22. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + total = 30 + rows = [ + _session(f"cli-{index:02d}", BASE_TS + (total - index)) + for index in range(total) + ] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {"cli-19", "cli-20", "cli-21"} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + (total - int(sid.split("-")[1])), + ) + + sessions = models.get_cli_sessions() + assert {s["session_id"] for s in sessions if s["project_id"] == "project-a"} == moved + + kept = _sidebar_rows_after_route(sessions, moved) + unassigned_cli = _unassigned_cli_ids(kept) + # Newest first by TIMESTAMP: cli-00..cli-18, then cli-22 (cli-19..cli-21 + # were moved). + assert unassigned_cli == [f"cli-{index:02d}" for index in range(19)] + ["cli-22"] + + +def test_refill_stops_at_the_cap_when_the_cap_is_all_there_is( + fake_hermes_home, tmp_path, monkeypatch +): + """17 unassigned conversations exist and 17 is what the sidebar shows. + + The loop must not keep widening after the database has handed over every + unassigned conversation it has — there is nothing left to find, so a wider + query would only re-read the same rows. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + rows = [_session(f"cli-{index:02d}", BASE_TS + index) for index in range(20)] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {"cli-19", "cli-18", "cli-17"} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + unassigned_queries = [] + real_reader = agent_sessions.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_assignment") == "unassigned": + unassigned_queries.append(kwargs.get("limit")) + return real_reader(*args, **kwargs) + + monkeypatch.setattr( + models, "read_importable_agent_session_rows", _counting_reader + ) + + sessions = models.get_cli_sessions() + kept = _sidebar_rows_after_route(sessions, moved) + + assert _unassigned_cli_ids(kept) == [f"cli-{index:02d}" for index in range(16, -1, -1)] + assert len(unassigned_queries) == 1 + + +def test_unassigned_refill_query_count_is_bounded( + fake_hermes_home, tmp_path, monkeypatch +): + """The re-examination loop is bounded, and it is bounded where it says. + + Every other conversation among the newest 40 is moved from the WebUI, which + is the shape that makes the precise widening converge slowest (each pass + discovers half as many new assignments as the last). The guarantee still + holds, and the whole refill pays at most + ``models.UNASSIGNED_CLI_REFILL_MAX_QUERIES`` unassigned queries. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + rows = [_session(f"cli-{index:02d}", BASE_TS + index) for index in range(80)] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {f"cli-{index:02d}" for index in range(40, 80) if index % 2 == 0} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + unassigned_queries = [] + real_reader = agent_sessions.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_assignment") == "unassigned": + unassigned_queries.append(kwargs.get("limit")) + return real_reader(*args, **kwargs) + + monkeypatch.setattr( + models, "read_importable_agent_session_rows", _counting_reader + ) + + sessions = models.get_cli_sessions() + + assert sum(1 for s in sessions if not s["project_id"]) == 20 + assert 1 < len(unassigned_queries) <= models.UNASSIGNED_CLI_REFILL_MAX_QUERIES + # Strictly widening, and never past the documented scan ceiling. + assert unassigned_queries == sorted(set(unassigned_queries)) + assert max(unassigned_queries) <= models.UNASSIGNED_CLI_REFILL_SCAN_CEILING + + kept = _sidebar_rows_after_route(sessions, moved) + assert len(_unassigned_cli_ids(kept)) == 20 + + +def _refill_cost(fake_hermes_home, tmp_path, monkeypatch, *, total, moved_indexes): + """Build the sidebar for one arrangement and report what it COST. + + Returns the width of every unassigned refill query plus the row counts the + model handed on, which is the part the sidebar cap cannot make cheaper: those + rows were read, sidecar-stat()ed and (on the gateway stream) serialised. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + rows = [_session(f"cli-{index:03d}", BASE_TS + index) for index in range(total)] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {f"cli-{index:03d}" for index in moved_indexes} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + unassigned_queries = [] + real_reader = agent_sessions.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_assignment") == "unassigned": + unassigned_queries.append(kwargs.get("limit")) + return real_reader(*args, **kwargs) + + monkeypatch.setattr( + models, "read_importable_agent_session_rows", _counting_reader + ) + + sessions = models.get_cli_sessions() + cli_rows = [s for s in sessions if s.get("is_cli_session")] + kept = _sidebar_rows_after_route(sessions, moved) + return { + "queries": unassigned_queries, + "model_rows": len(cli_rows), + "model_unassigned": sum(1 for s in cli_rows if not s["project_id"]), + "sidebar_unassigned": len(_unassigned_cli_ids(kept)), + } + + +def test_moving_the_newest_conversations_does_not_buy_a_ceiling_read( + fake_hermes_home, tmp_path, monkeypatch +): + """A filed-away newest 40 costs 60 rows, not the whole 200-row ceiling. + + The FIRST refill query here finds zero still-unassigned conversations (all 40 + it can reach were moved from the WebUI), so the proportional estimate has no + density to extrapolate from. Dividing by ``max(unassigned_seen, 1)`` instead + made it 20 * 40 = 800, clamped to the scan ceiling: every profile that had + moved >= 21 of its newest conversations jumped straight to a 200-row read + when the 60-row exact-width floor fills the window on the nose. + + Cost pin, not a correctness pin -- the window is full either way. Widths are + asserted exactly so a future widening cannot quietly buy the ceiling back. + """ + cost = _refill_cost( + fake_hermes_home, tmp_path, monkeypatch, + total=400, moved_indexes=range(360, 400), + ) + + assert cost["queries"] == [40, 60] + assert max(cost["queries"]) < models.UNASSIGNED_CLI_REFILL_SCAN_CEILING + # 60 rows carried to deliver 20, not 200 rows carrying 160 spares. + assert cost["model_rows"] == 60 + assert cost["model_unassigned"] == 20 + assert cost["sidebar_unassigned"] == 20 + + +def test_moves_clustered_at_the_window_edge_do_not_spend_the_whole_budget( + fake_hermes_home, tmp_path, monkeypatch +): + """Eight moves at the bottom edge cost two queries, not four. + + This is the other over-read shape. 19 unassigned conversations sit above the + moved block, so every re-count returns ``unassigned_seen == 19`` and the + proportional estimate advances by ~2 a time (21, 23, 25): all four queries + spent, the last of them reading the full ceiling band, and the model carrying + 200 rows / 192 of them unassigned to deliver 20. Geometric growth paced by the + remaining budget reaches the needed depth of 28 on the second query instead. + """ + cost = _refill_cost( + fake_hermes_home, tmp_path, monkeypatch, + total=200, moved_indexes=range(173, 181), + ) + + assert cost["queries"] == [21, 28] + assert len(cost["queries"]) < models.UNASSIGNED_CLI_REFILL_MAX_QUERIES + assert max(cost["queries"]) < models.UNASSIGNED_CLI_REFILL_SCAN_CEILING + # 28 rows for a 20-row window: the 8 moved conversations plus the window. + assert cost["model_rows"] == 28 + assert cost["model_unassigned"] == 20 + assert cost["sidebar_unassigned"] == 20 + + +def test_unassigned_refill_stops_widening_on_an_exhausted_database( + fake_hermes_home, tmp_path, monkeypatch +): + """No unassigned conversations left to find means no further queries. + + 25 conversations, the 10 oldest moved from the WebUI, so only 15 unassigned + conversations exist and the window can never reach 20. The loop widens once + (that pass does reach new conversations — all of them moved), then must + notice the next pass would only re-read the same rows and stop, instead of + re-querying up to its iteration cap on every sidebar build. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + rows = [_session(f"cli-{index:02d}", BASE_TS + index) for index in range(25)] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {f"cli-{index:02d}" for index in range(10)} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + unassigned_queries = [] + real_reader = agent_sessions.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_assignment") == "unassigned": + unassigned_queries.append(kwargs.get("limit")) + return real_reader(*args, **kwargs) + + monkeypatch.setattr( + models, "read_importable_agent_session_rows", _counting_reader + ) + + sessions = models.get_cli_sessions() + + # Every unassigned conversation in the database is delivered... + assert sum(1 for s in sessions if not s["project_id"]) == 15 + assert {s["session_id"] for s in sessions if s["project_id"] == "project-a"} == moved + # ...and the loop stopped as soon as widening stopped paying, well short of + # its iteration cap. + assert len(unassigned_queries) == 2 + + +def test_refill_handles_state_db_and_sidecar_assignments_together( + fake_hermes_home, tmp_path, monkeypatch +): + """A mixed store, including a row the two stores DISAGREE about. + + ``cli-29``/``cli-28`` are assigned in state.db (so the SQL filter already + excludes them), ``cli-10``/``cli-09`` only on their sidecars (so it does + not), and ``cli-27`` in BOTH, to different projects. state.db wins that + conflict — it is the agent's own record — but the classification that + matters to the window is the same either way: assigned, so it does not spend + an unassigned slot, and the widening must not double-count it either. + """ + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a", "project-b") + + state_assigned = {"cli-29", "cli-28", "cli-27"} + rows = [ + _session( + f"cli-{index:02d}", + BASE_TS + index, + project_id="project-a" if f"cli-{index:02d}" in state_assigned else None, + ) + for index in range(30) + ] + _write_state_db(fake_hermes_home / "state.db", rows) + + sidecar_assigned = {"cli-27", "cli-10", "cli-09"} + for sid in sorted(sidecar_assigned): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-b", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + sessions = models.get_cli_sessions() + + # state.db wins for the conflicted row; the sidecar-only pair keeps its own + # project; nothing else is assigned. + assert {s["session_id"]: s["project_id"] for s in sessions if s["project_id"]} == { + "cli-29": "project-a", + "cli-28": "project-a", + "cli-27": "project-a", + "cli-10": "project-b", + "cli-09": "project-b", + } + + # Every conversation with a sidecar is surfaced by the WebUI store, so the + # route sees all three of those as assigned rows. + kept = _sidebar_rows_after_route( + sessions, sidecar_assigned, project_id="project-b" + ) + assert _unassigned_cli_ids(kept) == [ + f"cli-{index:02d}" for index in list(range(26, 10, -1)) + [8, 7, 6, 5] + ] + + +def test_unassigned_refill_boundary_is_the_documented_scan_ceiling( + fake_hermes_home, tmp_path, monkeypatch +): + """Where the guarantee stops, and it stops exactly where the code says. + + The refill will not read deeper than + ``UNASSIGNED_CLI_REFILL_SCAN_CEILING`` conversations, because a build must + not be turned into a full-table scan by moving conversations into projects. + So the window is filled whenever at least 20 of the newest + ``ceiling`` state.db-unassigned conversations are still unassigned once + sidecars are read. Here 195 of the newest 210 were moved from the WebUI, so + only 5 unassigned conversations exist inside the ceiling: the sidebar is + honestly short, and the 10 unassigned conversations older than the ceiling + stay out of the payload (they remain reachable through search/pagination). + """ + ceiling = models.UNASSIGNED_CLI_REFILL_SCAN_CEILING + session_dir = tmp_path / "sessions" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + _register_projects(tmp_path, "project-a") + + total = ceiling + 10 + rows = [_session(f"cli-{index:03d}", BASE_TS + index) for index in range(total)] + _write_state_db(fake_hermes_home / "state.db", rows) + + moved = {f"cli-{index:03d}" for index in range(15, total)} + for sid in sorted(moved): + _write_webui_sidecar( + session_dir, + sid, + project_id="project-a", + updated_at=BASE_TS + int(sid.split("-")[1]), + ) + + unassigned_queries = [] + real_reader = agent_sessions.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_assignment") == "unassigned": + unassigned_queries.append(kwargs.get("limit")) + return real_reader(*args, **kwargs) + + monkeypatch.setattr( + models, "read_importable_agent_session_rows", _counting_reader + ) + + sessions = models.get_cli_sessions() + + unassigned = {s["session_id"] for s in sessions if not s["project_id"]} + # The unassigned conversations that fit inside the ceiling — cli-010..cli-014 + # are the newest 5 of the 15 — and nothing deeper. + assert unassigned == {f"cli-{index:03d}" for index in range(10, 15)} + assert len(unassigned_queries) <= models.UNASSIGNED_CLI_REFILL_MAX_QUERIES + assert max(unassigned_queries) == ceiling + + +def test_unassigned_window_counts_logical_conversations_not_segments( + fake_hermes_home, tmp_path +): + """The 20 unassigned rows must be 20 distinct conversations after dedup.""" + _register_projects(tmp_path, "project-a") + + rows = [ + _session(f"assigned-{index}", BASE_TS + 9000 + index, project_id="project-a") + for index in range(3) + ] + for index in range(25): + rows.extend(_lineage(f"chain{index:02d}", BASE_TS + index * 100, 3)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + unassigned = [s for s in sessions if not s["project_id"]] + lineage_keys = { + s.get("_lineage_root_id") or s["session_id"] for s in unassigned + } + + assert len(unassigned) == 20 + assert len(lineage_keys) == 20 + + +# ── Finding 3: a stale assignment must not hide a session ───────────────────── + + +@pytest.mark.parametrize("project_profile", ["default", "other-profile"]) +def test_unresolvable_project_id_leaves_the_session_unassigned( + fake_hermes_home, tmp_path, monkeypatch, project_profile +): + """A deleted or cross-profile id resolves to None BEFORE either cap runs. + + ``project-live`` is registered for the active profile; ``project-ghost`` is + either absent from projects.json entirely (deleted) or tagged to another + profile (foreign). Both must read as "unassigned" rather than exiling the row + to a ``default_hidden`` chip that cannot exist. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-live") + if project_profile != "default": + _register_projects(tmp_path, "project-ghost", profile=project_profile) + + rows = [ + _session("newest-ghost", BASE_TS + 500, project_id="project-ghost"), + _session("old-ghost", BASE_TS, project_id="project-ghost"), + _session("old-live", BASE_TS + 1, project_id="project-live"), + ] + rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + by_id = {session["session_id"]: session for session in sessions} + + # Inside the recent window: still present, and back in "Unassigned". + assert "newest-ghost" in by_id + assert by_id["newest-ghost"]["project_id"] is None + # Outside the window: not resurrected as an orphan chip-only row. The + # live-project row in the same db proves the cap itself still recovers. + assert "old-ghost" not in by_id + assert by_id["old-live"]["project_id"] == "project-live" + + +@pytest.mark.parametrize( + "row_profile, active, expected", + [ + (None, None, True), + ("", None, True), + ("default", None, True), + (None, "default", True), + ("", "default", True), + ("default", "default", True), + # Renamed root: the legacy 'default' tag and the display name are the + # same profile, in both directions. + ("kinni", "default", True), + ("default", "kinni", True), + (None, "kinni", True), + # Genuinely different profiles never cross. + ("other", "default", False), + ("default", "other", False), + ("other", "other", True), + ], +) +def test_profile_scoped_project_ids_uses_the_canonical_profile_match( + fake_hermes_home, tmp_path, monkeypatch, row_profile, active, expected +): + """Ownership is decided by ``_profiles_match``, not by a local copy of it. + + ``api/profiles.py::_profiles_match`` documents that it exists so callers + stop duplicating this None/''/'default'/renamed-root matrix. Each row asserts + the catalog read agrees with the canonical helper cell for cell, so the two + cannot drift apart the way the duplicated body could. + """ + import api.profiles as profiles + + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: active) + monkeypatch.setattr( + profiles, "_is_root_profile", lambda name: name in {"default", "kinni"} + ) + (tmp_path / "projects.json").write_text( + json.dumps([ + {"project_id": "p1", "name": "P1", "color": "#000", + "profile": row_profile, "created_at": 1.0}, + ]), + encoding="utf-8", + ) + models.clear_cli_sessions_cache() + + assert profiles._profiles_match(row_profile, active) is expected + assert ("p1" in models.profile_scoped_project_ids()) is expected + + +def test_unresolvable_project_overflow_is_not_marked_hidden(fake_hermes_home, tmp_path): + """End of the same chain: routes must see None, so no orphan default_hidden.""" + _register_projects(tmp_path, "project-live") + + rows = [_session("stale-assigned", BASE_TS, project_id="project-ghost")] + rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = sorted( + models.get_cli_sessions(), key=lambda s: s["updated_at"], reverse=True + ) + kept = routes._cap_recent_cli_sessions(sessions) + by_id = {row["session_id"]: row for row in kept} + + assert not any( + row.get("default_hidden") and not row.get("project_id") for row in kept + ) + # It is plain unassigned overflow now, so the recent cap drops it outright + # instead of parking it under a chip nothing can select. + assert "stale-assigned" not in by_id + + +# ── Finding 4: lineage project id on the ``tip is row`` early return ────────── + + +def test_lineage_project_id_kept_when_root_is_the_freshest_segment(): + """The assignment lives on a newer EMPTY continuation (agent_sessions:468). + + ``compression_tip()`` resolves the project id across the whole lineage but + returns the ROOT as the tip (the continuation has no messages), so the + ``tip is row`` early return has to apply it too. + """ + root = _session( + "root", 100.0, ended_at=110.0, end_reason="compression", messages=4, + ) + root["actual_message_count"] = 4 + root["last_activity"] = 120.0 + empty_tip = _session("tip", 200.0, project_id="project-123", parent="root", title="") + empty_tip["actual_message_count"] = 0 + empty_tip["last_activity"] = 200.0 + + projected = agent_sessions._project_agent_session_rows([root, empty_tip]) + + assert [row["id"] for row in projected] == ["root"] + assert projected[0]["project_id"] == "project-123" + + +def test_assignment_on_empty_continuation_reaches_the_sidebar( + fake_hermes_home, tmp_path, monkeypatch +): + """Same case end-to-end: the recovery pass must find and keep the lineage.""" + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-123") + + rows = [ + _session("root-with-messages", BASE_TS, ended_at=BASE_TS + 1, end_reason="compression"), + _session( + "empty-continuation", + BASE_TS + 2, + project_id="project-123", + parent="root-with-messages", + messages=0, + ), + ] + rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + by_id = {session["session_id"]: session for session in sessions} + + assert "root-with-messages" in by_id + assert by_id["root-with-messages"]["project_id"] == "project-123" + # The empty continuation is not a separately addressable conversation. + assert "empty-continuation" not in by_id + + +# ── greptile P1, second half: compression segments eating the raw window ────── + + +def test_candidate_window_widens_when_segments_consume_it(tmp_path): + """``limit`` counts logical conversations, so a short window must re-widen. + + 12 ten-segment chains: the first 8x candidate window (80 raw rows) only + reaches 8 whole chains, so a request for 10 conversations came back with 8. + """ + rows = [] + for index in range(12): + rows.extend(_lineage(f"chain{index:02d}", BASE_TS + index * 10_000, 10)) + db_path = tmp_path / "state.db" + _write_state_db(db_path, rows) + + projected = agent_sessions.read_importable_agent_session_rows( + db_path, limit=10, exclude_sources=None + ) + + assert len(projected) == 10 + assert len({row["_lineage_root_id"] for row in projected}) == 10 + + +def test_project_assignment_filters_are_exact_complements(tmp_path): + """'assigned' and 'unassigned' must partition the logical conversations.""" + rows = [ + _session("plain-a", BASE_TS + 10), + _session("plain-b", BASE_TS + 20), + _session("owned", BASE_TS + 30, project_id="project-a"), + ] + # A lineage whose assignment only exists on the tip is assigned as a whole. + rows.extend(_lineage("chain", BASE_TS + 40, 3, tip_project_id="project-b")) + db_path = tmp_path / "state.db" + _write_state_db(db_path, rows) + + def _ids(**kwargs): + return { + row["id"] + for row in agent_sessions.read_importable_agent_session_rows( + db_path, limit=50, exclude_sources=None, **kwargs + ) + } + + everything = _ids() + assigned = _ids(project_assignment="assigned") + unassigned = _ids(project_assignment="unassigned") + + assert assigned == {"owned", "chain-seg2"} + assert unassigned == {"plain-a", "plain-b"} + assert assigned | unassigned == everything + assert not assigned & unassigned + + +def test_project_assignment_rejects_unknown_values(tmp_path): + db_path = tmp_path / "state.db" + _write_state_db(db_path, [_session("plain", BASE_TS)]) + + with pytest.raises(ValueError): + agent_sessions.read_importable_agent_session_rows( + db_path, limit=5, project_assignment="maybe" + ) + + +def test_assigned_filter_is_empty_on_schemas_without_project_id(tmp_path): + db_path = tmp_path / "state.db" + _write_state_db(db_path, [_session("plain", BASE_TS)], lineage_columns=False) + with sqlite3.connect(str(db_path)) as conn: + conn.execute("ALTER TABLE sessions DROP COLUMN project_id") + + assert agent_sessions.read_importable_agent_session_rows( + db_path, limit=5, exclude_sources=None, project_assignment="assigned" + ) == [] + unassigned = agent_sessions.read_importable_agent_session_rows( + db_path, limit=5, exclude_sources=None, project_assignment="unassigned" + ) + assert [row["id"] for row in unassigned] == ["plain"] + + +def test_schema_without_project_id_retains_prior_behavior(fake_hermes_home, tmp_path): + """An old state.db that cannot persist assignments is unchanged by all this. + + The profile HAS a project registered, so the recovery pass is only skipped by + the schema probe. ``_state_db_supports_project_ids`` is asserted directly + because dropping the probe is behaviour-neutral (the assigned filter already + returns nothing for such a schema) — it is a query-budget guard, so the + contract worth pinning is the probe's own answer plus the unchanged output. + """ + _register_projects(tmp_path, "project-live") + + db_path = fake_hermes_home / "state.db" + rows = [_session(f"plain-{index:02d}", BASE_TS + index) for index in range(25)] + _write_state_db(db_path, rows, lineage_columns=False) + assert models._state_db_supports_project_ids(db_path) is True + with sqlite3.connect(str(db_path)) as conn: + conn.execute("ALTER TABLE sessions DROP COLUMN project_id") + models.clear_cli_sessions_cache() + + assert models._state_db_supports_project_ids(db_path) is False + + sessions = models.get_cli_sessions() + + assert len(sessions) == models.CLI_VISIBLE_SESSION_LIMIT == 20 + assert all(session["project_id"] is None for session in sessions) + # Still the newest 20, in the pre-existing order. + assert [session["session_id"] for session in sessions][:3] == [ + "plain-24", "plain-23", "plain-22", + ] + + +def test_unassigned_refill_runs_when_the_mixed_window_under_delivers( + fake_hermes_home, tmp_path, monkeypatch +): + """A SHORT mixed first pass is still a shortfall the refill must repair. + + The first pass can return fewer than ``CLI_VISIBLE_SESSION_LIMIT`` + conversations with plenty left in the db, because compression segments spend + its RAW candidate window (8x, then 32x) before the logical slice. Gating the + refill on "the first pass came back full" left the unassigned window short in + exactly that case. Three 43-segment assigned chains saturate both candidate + windows and yield 3 of the 4 requested conversations; the narrower + unassigned-only query skips those chains and can still deliver 4. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 4) + _register_projects(tmp_path, "project-live") + + rows = [_session(f"plain-{index:02d}", BASE_TS + 100 + index) for index in range(10)] + for index in range(3): + rows.extend(_lineage( + f"deep{index}", + BASE_TS + 2000 + index * 1000, + 43, + project_id="project-live", + step=1.0, + )) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + unassigned = sorted(s["session_id"] for s in sessions if s["project_id"] is None) + + # Was 0: the mixed pass returned 3 conversations, so the old "was it + # saturated?" gate concluded the db had nothing left to give. + assert unassigned == ["plain-06", "plain-07", "plain-08", "plain-09"] + # The lineage-heavy assigned conversations are still there, once each. + assert sum(1 for s in sessions if s["project_id"] == "project-live") == 3 + + +def test_unassigned_refill_cannot_clobber_a_resolved_assignment( + fake_hermes_home, tmp_path +): + """Recovery pass 2 must never un-fix this PR's own headline bug. + + Pass 2 merges its rows into the same lineage-keyed table pass 1 fills, and + it used to overwrite an existing entry unconditionally. When the SQL + ``project_assignment='unassigned'`` filter and the Python lineage projection + disagree about where a compression lineage starts, pass 2 hands back the + lineage ROOT as its own "unassigned" conversation — and the unconditional + overwrite then replaced the assigned row with it, deleting the project chip + and swapping the session identity from the lineage tip back to the root. + + They disagree here on a blank ``source``: ``_is_continuation_session`` + treats an empty/whitespace source as "unknown, same conversation", while the + SQL lineage CTE compares ``LOWER(TRIM(parent.source)) = LOWER(TRIM(...))`` + and so refuses to walk from the assigned tip up to the blank-source root. + The merge has to be safe whether or not the two ever line up exactly. + """ + _register_projects(tmp_path, "project-live") + + rows = [ + # Blank source: SQL will not join this root to its assigned tip, but the + # Python projection collapses the pair into one row keyed on both ids. + _session( + "chain-root", + BASE_TS + 500, + source="", + ended_at=BASE_TS + 505, + end_reason="compression", + ), + _session( + "chain-tip", BASE_TS + 510, parent="chain-root", project_id="project-live" + ), + ] + # Two spare unassigned rows so the window is short and pass 2 actually runs. + rows.extend(_session(f"plain-{index}", BASE_TS + index) for index in range(2)) + _write_state_db(fake_hermes_home / "state.db", rows) + + by_id = {s["session_id"]: s for s in models.get_cli_sessions()} + + # Was: "chain-tip" gone, "chain-root" present with project_id None. + assert "chain-tip" in by_id, "pass 2 replaced the assigned row with its root" + assert by_id["chain-tip"]["project_id"] == "project-live" + assert "chain-root" not in by_id, "the lineage must stay one conversation" + + +# ── Rebase guard: upstream's kanban pass shares the system-chip helper ──────── + + +def test_background_source_exclusion_literal_matches_the_constant(): + """The interactive exclusion stays spelled as a tuple literal. + + ``tests/test_issue2841_show_cron_sessions_toggle.py`` reads that call site as + SOURCE TEXT (``'("cron", "webhook", "kanban") if source_filter is None'``), + so replacing the literal with ``BACKGROUND_CLI_SOURCES`` turned a + pre-existing test red. The literal is therefore kept, and this test pins it + to the constant the new code paths use so the two cannot drift apart. + """ + from pathlib import Path + + source = Path(models.__file__).read_text(encoding="utf-8") + literal = ", ".join(f'"{name}"' for name in models.BACKGROUND_CLI_SOURCES) + assert f"exclude_sources=({literal}) if source_filter is None else None" in source, ( + "the interactive exclude_sources literal must list exactly " + f"BACKGROUND_CLI_SOURCES ({models.BACKGROUND_CLI_SOURCES!r})" + ) + assert '("cron", "webhook", "kanban") if source_filter is None' in source, ( + "test_issue2841_show_cron_sessions_toggle.py pins this exact substring" + ) + + +def test_kanban_second_pass_still_projects_rows(fake_hermes_home, tmp_path): + """The system-chip helper keeps the (sid, source) signature kanban calls. + + Upstream's kanban pass calls ``_state_row_project_id(sid, _source)``; giving + that helper a row-dict signature turned the whole pass into a swallowed + TypeError and silently dropped every kanban row. + """ + _register_projects(tmp_path, "project-live") + + rows = [_session("kanban-old", BASE_TS, source="kanban")] + rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + sessions = models.get_cli_sessions() + by_id = {session["session_id"]: session for session in sessions} + + assert "kanban-old" in by_id + # Upstream semantics: kanban rows get no chip from this helper. + assert by_id["kanban-old"]["project_id"] is None + + +def test_background_row_chip_is_the_same_on_both_projection_paths( + fake_hermes_home, tmp_path +): + """One kanban row must not report two different chips. + + The interactive projection loop is also what a ``source_filter='kanban'`` + scan walks, so resolving state.db assignments there without excusing + background sources gave the SAME row ``None`` in the default view (its own + bounded second pass) and a resolved id under ``?source=kanban``. + """ + _register_projects(tmp_path, "project-live") + + rows = [_session("kan-1", BASE_TS, source="kanban", project_id="project-live")] + rows.extend( + _session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25) + ) + _write_state_db(fake_hermes_home / "state.db", rows) + + default_view = {s["session_id"]: s for s in models.get_cli_sessions()} + kanban_view = {s["session_id"]: s for s in models.get_cli_sessions("kanban")} + + assert "kan-1" in default_view + assert "kan-1" in kanban_view + assert default_view["kan-1"]["project_id"] is None + assert kanban_view["kan-1"]["project_id"] == default_view["kan-1"]["project_id"] + + +# ── The all-profiles sidebar view runs the same recovery passes ─────────────── + + +def test_all_profiles_view_recovers_project_assigned_sessions( + fake_hermes_home, tmp_path, monkeypatch +): + """``?all_profiles=1`` truncates by recency too, so it needs both passes. + + That path passes ``visible_session_limit=None``, which reads like + "unbounded" but is mapped to ``CLI_VISIBLE_SESSION_LIMIT`` for the + interactive pass — only the cron/webhook/kanban limits reach the reader as + ``limit=`` directly, where None really does mean unbounded. Skipping the + assigned recovery pass here therefore lost exactly the sessions this + regression is about, in the one view that shows every profile at once. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-123") + + db_path = fake_hermes_home / "state.db" + rows = [ + _session(f"recent-{index:02d}", BASE_TS + 100 + index) + for index in range(25) + ] + rows.append(_session("older-assigned", BASE_TS, project_id="project-123")) + _write_state_db(db_path, rows) + + # Enumerating real profiles shells out to the agent CLI; pin the one context. + monkeypatch.setattr( + models, + "_all_profiles_cli_contexts", + lambda: ([(fake_hermes_home, db_path, "default")], (("home", "default", 1),)), + ) + + sessions = models.get_cli_sessions(all_profiles=True) + by_id = {session["session_id"]: session for session in sessions} + + assert "older-assigned" in by_id + assert by_id["older-assigned"]["project_id"] == "project-123" + assert [s["session_id"] for s in sessions].count("older-assigned") == 1 + # The unassigned window is still bounded — the recovery pass is additive. + assert sum(1 for s in sessions if s["project_id"] is None) == 5 + + +def test_all_profiles_view_resolves_each_context_against_its_own_profile( + fake_hermes_home, tmp_path, monkeypatch +): + """Review finding 3 asked for "the SESSION's profile", not the active one. + + ``_load_cli_sessions_uncached`` runs once per profile context here, but the + project catalog it resolved against came from ``get_active_profile_name()``. + So a LIVE assignment owned by another profile was misread as unresolvable: + coerced to None, skipped by the assigned recovery pass, and unreachable by the + unassigned refill too (its state.db row DOES carry a ``project_id``, which the + "unassigned" SQL filter excludes) — the row left the payload entirely. Its + chip is selectable in this very view (``/api/projects`` returns every project + when ``all_profiles=1``), so selecting it showed nothing: exactly the + "assignment exists but the session is unreachable" failure finding 3 named. + """ + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + _register_projects(tmp_path, "project-a") + _register_projects(tmp_path, "project-b", profile="other") + + home_a = fake_hermes_home + db_a = home_a / "state.db" + rows_a = [_session(f"a-recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)] + rows_a.append(_session("older-assigned-a", BASE_TS, project_id="project-a")) + _write_state_db(db_a, rows_a) + + home_b = tmp_path / "hermes-other" + home_b.mkdir() + db_b = home_b / "state.db" + rows_b = [_session(f"b-recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)] + rows_b.append(_session("older-assigned-b", BASE_TS, project_id="project-b")) + # The safe direction, in the same scan: project-a belongs to the OTHER + # profile as far as this context is concerned, so it must still coerce to + # None instead of gaining a chip that cannot reveal it. + rows_b.append(_session("newest-foreign-b", BASE_TS + 900, project_id="project-a")) + _write_state_db(db_b, rows_b) + + # Enumerating real profiles shells out to the agent CLI; pin the two contexts. + monkeypatch.setattr( + models, + "_all_profiles_cli_contexts", + lambda: ( + [(home_a, db_a, "default"), (home_b, db_b, "other")], + (("home-a", "default", 1), ("home-b", "other", 1)), + ), + ) + + sessions = models.get_cli_sessions(all_profiles=True) + by_id = {session["session_id"]: session for session in sessions} + + # The active profile's own context is unaffected. + assert by_id["older-assigned-a"]["project_id"] == "project-a" + # The other profile's LIVE assignment survives, with its own chip. + assert "older-assigned-b" in by_id + assert by_id["older-assigned-b"]["project_id"] == "project-b" + # A foreign id is still not an assignment. + assert by_id["newest-foreign-b"]["project_id"] is None + + +@pytest.mark.parametrize("quiet_count", [1, 3]) +def test_compression_heavy_widening_is_one_pass_budget_not_per_project( + fake_hermes_home, tmp_path, monkeypatch, quiet_count +): + """The retry after a binding scoped window is paid from ONE pass budget. + + ``remaining`` bounds each starved project's first scoped query, but the + widening that follows a binding raw window jumped straight to the global + ``query_limit`` — and it was granted to every starved project in turn. A + profile with ten compression-heavy projects therefore scanned ten times the + ceiling in a single build, growing with the number of starved projects + (greptile P1 on #6659). The widening now comes out of one pass-level pool + shared FAIRLY: each starved project's retry is funded by a slice of what + the pool still holds (one slice per project not yet tried), and the pool is + debited only for what a retry reads beyond the project's own width, so the + scoped reading of a build is bounded by ``3 * query_limit`` however many + projects are starved — while every starved project still pays its own first + query and still keeps pool for its own first retry, the completeness + guarantees neither a per-build project cap nor a first-come whole-grant + debit may break (both greptile P1 reviews on #6659). + + Parametrized over the number of starved projects because that is exactly + what the budget must not scale with. + """ + ceiling = 20 + monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5) + monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", ceiling) + per_project_limit = 4 + quiet = [f"project-quiet-{index}" for index in range(quiet_count)] + _register_projects(tmp_path, "project-busy", *quiet) + + project_count = len(quiet) + 1 + # The pass's own query budget: every project's equal share of the ceiling. + effective_limit = min(per_project_limit, max(1, ceiling // project_count)) + query_limit = min(effective_limit * project_count, ceiling) + + # Each quiet project's scoped raw window is consumed by its OWN compression + # segments, so its first scoped query comes back short with older assigned + # conversations of its own still waiting behind the window. + scoped_raw_window = per_project_limit * max(agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS) + rows = [] + for index, project_id in enumerate(quiet): + rows.append(_session(f"{project_id}-old", BASE_TS + index, project_id=project_id)) + rows.extend(_lineage( + f"{project_id}-chain", + BASE_TS + 10_000 + index * 1_000, + scoped_raw_window + 100, + project_id=project_id, + step=1.0, + )) + rows.extend( + _session(f"busy-{index:03d}", BASE_TS + 5_000 + index, project_id="project-busy") + for index in range(25) + ) + rows.extend(_session(f"plain-{index:02d}", BASE_TS + 90_000 + index) for index in range(25)) + _write_state_db(fake_hermes_home / "state.db", rows) + + scoped_queries = [] + real_reader = models.read_importable_agent_session_rows + + def _counting_reader(*args, **kwargs): + if kwargs.get("project_ids"): + scoped_queries.append((kwargs["project_ids"][0], kwargs.get("limit"))) + return real_reader(*args, **kwargs) + + monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader) + + sessions = models._load_cli_sessions_uncached( + fake_hermes_home, + fake_hermes_home / "state.db", + "default", + project_assigned_limit=per_project_limit, + ) + + # Completeness is untouched: EVERY starved project still gets its query. + # (The busy project is starved in this fixture too and is queried as well; + # what matters here is that no project of interest is skipped.) + tried = {project_id for project_id, _ in scoped_queries} + assert set(quiet) <= tried + + # A widened query is one that asks for more than the project's own share. + widened = [limit for _, limit in scoped_queries if limit > effective_limit] + # The pool is shared fairly: EVERY starved project gets a widening retry — + # not just the first in line, and not zero of them. With one starved + # project the pool is entirely its own; with several, each retry is funded + # by a slice of what remains so later projects keep pool for theirs. + # Bounded by the number of retrying projects, not by the pool arithmetic + # (a single starved project legitimately spends the whole pool on one + # retry to `query_limit`). + assert len(widened) == min(quiet_count, len(quiet)) + # Every retry covers the whole project: the reader's own 32x oversample + # turns a query at `limit` into a raw read of at most + # `limit * max(CANDIDATE_WINDOW_MULTIPLIERS)` rows, so the retry grants + # stay under `query_limit * 32` — but the retries must never buy the + # unbounded per-project read the original P1 described. + assert all( + limit <= query_limit * max(agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS) + for limit in widened + ) + # The retries are PAID FROM THE POOL, not granted per project: the sum of + # what they read beyond their projects' own widths stays within the pool + # plus each project's one honest re-read of its own width — the aggregate + # bound that keeps the pass constant in the number of starved projects. + assert sum(widened) <= len(widened) * query_limit + # And the whole pass stays inside the documented aggregate bound, + # whatever the number of starved projects. + assert sum(limit for _, limit in scoped_queries) <= 3 * query_limit + + # The widening the budget funded still recovers the conversation behind + # EVERY starved project's window — the retry was rationed, not disabled. + for project_id in quiet: + assert f"{project_id}-old" in { + session["session_id"] for session in sessions + }, f"the funded widening must still reach behind {project_id}'s window"