fix(sessions): bound the merged assigned CLI set at the 200-row cap

The review asked for "the existing 200-row assigned cap across the final
merged CLI set". The per-project budget was only tightened by an equal
share of a 2000-row ceiling, so it bit past 10 assigned projects and left
the review's own reproduction intact: 1,000 assigned conversations spread
over 5 projects returned all 1,000 (2000 // 5 = 400, every project stayed
under its own 200).

Bound the merged assigned payload at CLI_PROJECT_ASSIGNED_CAP and spend
that budget with a round-robin draw across the projects, newest first
within each project, so bounding the payload cannot starve a quiet project
the way a flat sessions[:200] truncation would (greptile P1).

CLI_PROJECT_ASSIGNED_SCAN_CEILING is gone: 200 is strictly tighter than
the model's 2000-row scan ceiling, so mirroring it here was dead. Its
drift guard is replaced by a stronger one pinning the route cap to
models.PROJECT_ASSIGNED_CLI_LIMIT and to <= the model scan ceiling.
This commit is contained in:
Rodrigo Gomes da Silva committed 2026-09-24 11:03:46 -03:00
1 parent 76be369c54
commit bc80d71177
2 files changed
+175 -102

No files matched your search

+85 -47
View File
@@ -10488,29 +10488,73 @@ def _cli_visible_session_cap() -> int:
return CLI_VISIBLE_SESSION_LIMIT return CLI_VISIBLE_SESSION_LIMIT
# Per-project bound on project-assigned CLI rows in the FINAL MERGED payload. # Bound on project-assigned CLI rows in the FINAL MERGED payload, across EVERY
# This is the only place that sees every source of assigned rows at once — # project. This is the only place that sees every source of assigned rows at
# state.db's own bounded passes plus imported WebUI sidecars from all_sessions(), # once — state.db's own bounded passes plus imported WebUI sidecars from
# which no model-side cap applies to — so it is the only place that can actually # all_sessions(), which no model-side cap applies to — so it is the only place
# bound the assigned set (#6659 review finding 1). # 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 (itself resolved from the shared
# configurable sidebar window's ceiling on the model side) by
# test_route_merged_assigned_cap_is_the_existing_model_row_cap.
CLI_PROJECT_ASSIGNED_CAP = 200 CLI_PROJECT_ASSIGNED_CAP = 200
# Global ceiling on the assigned rows this cap will emit, mirroring
# models.PROJECT_ASSIGNED_CLI_SCAN_CEILING so the route can never hand back more
# assigned conversations than the model pass was allowed to recover. A flat def _draw_assigned_cli_rows_fairly(
# per-project bound does NOT bound the payload: 200 rows x N projects grows with rows_by_project: dict[str, list[int]], budget: int
# the project count, and 1,000 assigned rows spread over 5 projects still ) -> set[int]:
# returned all 1,000 — the exact reproduction from #6659 review finding 1. """Pick ``budget`` assigned row indices, spread fairly across the projects.
# Spelled as a literal because api.models is imported further down this module;
# pinned equal to the model constant by ``rows_by_project`` maps project id -> that project's row indices, newest
# test_route_project_ceiling_matches_the_model_constant. first, keyed in order of each project's most recent assigned conversation
CLI_PROJECT_ASSIGNED_SCAN_CEILING = 2000 (``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( def _cap_recent_cli_sessions(
sessions: list[dict], sessions: list[dict],
cli_cap: int | None = None, cli_cap: int | None = None,
project_cap: int = CLI_PROJECT_ASSIGNED_CAP, project_cap: int = CLI_PROJECT_ASSIGNED_CAP,
project_ceiling: int = CLI_PROJECT_ASSIGNED_SCAN_CEILING,
) -> list[dict]: ) -> list[dict]:
"""Cap the default CLI list while retaining project-addressable rows. """Cap the default CLI list while retaining project-addressable rows.
@@ -10525,43 +10569,39 @@ def _cap_recent_cli_sessions(
to a project silently shortens everyone's sidebar to 17 rows. Resolved to a project silently shortens everyone's sidebar to 17 rows. Resolved
lazily from the shared configurable window (HERMES_WEBUI_VISIBLE_SESSION_LIMIT), lazily from the shared configurable window (HERMES_WEBUI_VISIBLE_SESSION_LIMIT),
never a second hard-coded 20. never a second hard-coded 20.
* ``project_cap`` assigned conversations PER PROJECT stay in the payload so * ``project_cap`` assigned conversations IN TOTAL, across every project, stay
the project chip can reveal them, marked ``default_hidden`` once the recent in the payload so the project chips can reveal them, marked
window is full. Past that bound they are dropped: keeping assigned rows past ``default_hidden`` once the recent window is full. Past that bound they are
the *recent* cap is the fix, keeping them past *all* bounds just trades a dropped: keeping assigned rows past the *recent* cap is the fix, keeping
vanishing session for a stalled sidebar. them past *all* bounds just trades a vanishing session for a stalled
sidebar.
That per-project budget is an EQUAL share of ``project_ceiling`` That assigned budget is spent by a fair round-robin draw across the projects
(``ceiling // projects``, never below 1) once enough projects exist to exceed (see ``_draw_assigned_cli_rows_fairly``) instead of by truncating the merged
it, which is what makes the merged payload bounded no matter how many list, so bounding the payload cannot starve a quiet project (greptile P1 on
projects a profile has while keeping a project's allowance independent of how #6659). ``project_cap <= 0`` disables the assigned bound entirely.
loud its neighbours are. Same shape as the model-side share of
``PROJECT_ASSIGNED_CLI_SCAN_CEILING``, and for the same reason a flat global
cap is not usable here: it would leave every project past it permanently
unreachable on every rebuild (greptile P1 on #6659).
""" """
if cli_cap is None: if cli_cap is None:
cli_cap = _cli_visible_session_cap() cli_cap = _cli_visible_session_cap()
if cli_cap <= 0: if cli_cap <= 0:
return sessions return sessions
# The share denominator has to be known before the first assigned row is # Group the assigned rows per project first: the draw has to weigh the
# charged against it, so count the distinct assigned projects up front. # projects against each other, which a single forward pass cannot do.
assigned_projects = { rows_by_project: dict[str, list[int]] = {}
project_id for index, session in enumerate(sessions):
for session in sessions if not _is_cli_session_for_settings(session):
if _is_cli_session_for_settings(session) continue
and (project_id := str(session.get("project_id") or "").strip()) project_id = str(session.get("project_id") or "").strip()
} if project_id:
effective_project_cap = project_cap rows_by_project.setdefault(project_id, []).append(index)
if project_cap > 0 and project_ceiling > 0 and assigned_projects: drawn = (
effective_project_cap = min( None if project_cap <= 0
project_cap, max(1, project_ceiling // len(assigned_projects)) else _draw_assigned_cli_rows_fairly(rows_by_project, project_cap)
) )
kept = [] kept = []
recent_seen = 0 recent_seen = 0
unassigned_seen = 0 unassigned_seen = 0
assigned_seen: dict[str, int] = {} for index, session in enumerate(sessions):
for session in sessions:
if _is_cli_session_for_settings(session): if _is_cli_session_for_settings(session):
project_id = str(session.get("project_id") or "").strip() project_id = str(session.get("project_id") or "").strip()
if not project_id: if not project_id:
@@ -10570,9 +10610,7 @@ def _cap_recent_cli_sessions(
continue continue
recent_seen += 1 recent_seen += 1
else: else:
seen = assigned_seen.get(project_id, 0) + 1 if drawn is not None and index not in drawn:
assigned_seen[project_id] = seen
if effective_project_cap > 0 and seen > effective_project_cap:
continue continue
if recent_seen >= cli_cap: if recent_seen >= cli_cap:
session = dict(session) session = dict(session)
@@ -12,9 +12,11 @@ swing to the opposite failure, so these tests pin BOTH edges of the contract:
is followed by project-scoped, per-project-budgeted queries (greptile P1); is followed by project-scoped, per-project-budgeted queries (greptile P1);
* the follow-up is COMPLETE — every starved project is served, with no fixed * 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); per-build project cap that would permanently skip the rest (greptile P1);
* the recovered set is BOUNDED — per project, counting logical conversations — * the recovered set is BOUNDED — the FINAL MERGED assigned payload never exceeds
including assigned rows that arrive as imported WebUI sidecars and therefore the 200-row assigned cap, counting logical conversations, including assigned
never pass through any state.db cap; 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 * assigned rows do not spend the unassigned sidebar quota: 20 unique unassigned
logical conversations survive after lineage/sidecar dedup; logical conversations survive after lineage/sidecar dedup;
* a deleted or cross-profile ``project_id`` cannot hide a session — it resolves * a deleted or cross-profile ``project_id`` cannot hide a session — it resolves
@@ -291,11 +293,57 @@ def test_route_cap_bounds_imported_sidecar_assigned_rows(fake_hermes_home):
assert not any("default_hidden" in row for row in sidecars) assert not any("default_hidden" in row for row in sidecars)
def test_route_cap_bounds_each_project_independently(fake_hermes_home): 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.
counts = _assigned_project_counts(kept)
assert len(counts) == project_count
assert set(counts.values()) == {routes.CLI_PROJECT_ASSIGNED_CAP // project_count}
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). """A busy project must not evict another project's history (greptile P1).
The bound is per project, so a profile with more than The merged budget is drawn round-robin, newest first WITHIN each project, so
``CLI_PROJECT_ASSIGNED_CAP`` assigned conversations in TOTAL keeps them all. two equally deep projects get an equal share and each keeps its newest
conversations rather than an arbitrary slice.
""" """
rows = [] rows = []
for project in ("project-a", "project-b"): for project in ("project-a", "project-b"):
@@ -310,27 +358,26 @@ def test_route_cap_bounds_each_project_independently(fake_hermes_home):
kept = routes._cap_recent_cli_sessions(rows) kept = routes._cap_recent_cli_sessions(rows)
counts: dict[str, int] = {} half = routes.CLI_PROJECT_ASSIGNED_CAP // 2
for row in kept: assert _assigned_project_counts(kept) == {"project-a": half, "project-b": half}
counts[row["project_id"]] = counts.get(row["project_id"], 0) + 1 assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP
assert counts == { # Newest first within each project: the draw keeps the head of each queue.
"project-a": routes.CLI_PROJECT_ASSIGNED_CAP, kept_ids = {row["session_id"] for row in kept}
"project-b": routes.CLI_PROJECT_ASSIGNED_CAP, for project in ("project-a", "project-b"):
} assert f"{project}-0000" in kept_ids
assert len(kept) > routes.CLI_PROJECT_ASSIGNED_CAP assert f"{project}-{half - 1:04d}" in kept_ids
assert f"{project}-{half:04d}" not in kept_ids
def test_route_cap_bounds_the_whole_merged_assigned_set(fake_hermes_home): def test_route_cap_never_starves_a_project_with_more_projects_than_slots(
"""A per-project bound alone is not a bound on the payload (finding 1). fake_hermes_home,
):
"""Every project with an assigned session keeps at least one row.
``CLI_PROJECT_ASSIGNED_CAP`` rows times N projects grows with the project 40 projects x 200 rows still fits the merged cap because 200 // 40 = 5 — the
count: 1,000 assigned conversations spread over 5 projects reproduced the draw shrinks each project's slice instead of dropping whole projects.
review's original "1,000 rows in, 1,000 rows out" exactly, because every
project stayed under its own 200. The equal share of
``CLI_PROJECT_ASSIGNED_SCAN_CEILING`` is what actually bounds the merged set.
""" """
project_count = 40 project_count = 40
per_project = 200
rows = [] rows = []
for project in range(project_count): for project in range(project_count):
rows.extend( rows.extend(
@@ -339,31 +386,23 @@ def test_route_cap_bounds_the_whole_merged_assigned_set(fake_hermes_home):
"is_cli_session": True, "is_cli_session": True,
"project_id": f"project-{project:02d}", "project_id": f"project-{project:02d}",
} }
for index in range(per_project) for index in range(200)
) )
assert len(rows) > routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
kept = routes._cap_recent_cli_sessions(rows) kept = routes._cap_recent_cli_sessions(rows)
assert len(kept) <= routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING counts = _assigned_project_counts(kept)
# Bounded by an EQUAL share, not by dropping whole projects: every project assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP
# keeps a slice, or this would be the starvation greptile P1 rejected.
counts: dict[str, int] = {}
for row in kept:
counts[row["project_id"]] = counts.get(row["project_id"], 0) + 1
share = routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING // project_count
assert len(counts) == project_count assert len(counts) == project_count
assert set(counts.values()) == {share} assert min(counts.values()) >= 1
# The share only bites once it is tighter than the per-project cap. assert set(counts.values()) == {routes.CLI_PROJECT_ASSIGNED_CAP // project_count}
assert share < routes.CLI_PROJECT_ASSIGNED_CAP
def test_route_cap_share_does_not_shrink_below_the_per_project_cap(fake_hermes_home): def test_route_cap_keeps_every_assigned_row_under_the_merged_cap(fake_hermes_home):
"""Few projects must keep the full 200 — the ceiling is a ceiling, not a quota. """Negative control: the draw is inert while the assigned set fits.
Negative control for the test above: with the ceiling comfortably wider than Without this, "bounded at 200" could just as well be a blanket shrink. Two
``project_count * CLI_PROJECT_ASSIGNED_CAP`` the share is inert, so the projects with 60 conversations each keep all 120.
bounding above is attributable to the ceiling and not to a blanket shrink.
""" """
rows = [] rows = []
for project in ("project-a", "project-b"): for project in ("project-a", "project-b"):
@@ -373,31 +412,27 @@ def test_route_cap_share_does_not_shrink_below_the_per_project_cap(fake_hermes_h
"is_cli_session": True, "is_cli_session": True,
"project_id": project, "project_id": project,
} }
for index in range(250) for index in range(60)
) )
kept = routes._cap_recent_cli_sessions(rows) kept = routes._cap_recent_cli_sessions(rows)
counts: dict[str, int] = {} assert _assigned_project_counts(kept) == {"project-a": 60, "project-b": 60}
for row in kept: assert len(kept) == 120 < routes.CLI_PROJECT_ASSIGNED_CAP
counts[row["project_id"]] = counts.get(row["project_id"], 0) + 1
assert counts == {
"project-a": routes.CLI_PROJECT_ASSIGNED_CAP,
"project-b": routes.CLI_PROJECT_ASSIGNED_CAP,
}
def test_route_project_ceiling_matches_the_model_constant(): def test_route_merged_assigned_cap_is_the_existing_model_row_cap():
"""The route literal must not drift from the model-side scan ceiling. """The route's merged bound must stay the 200-row cap the review named.
Same reasoning as ``test_background_source_exclusion_literal_matches_the_constant``: ``api.models`` is imported below this cap in ``api/routes.py``, so the value
the value is duplicated because ``api.models`` is imported below this cap in is spelled as a literal there and only a test can keep the two honest. The
``api/routes.py``, so only a test can keep the two honest. If they diverge the merged bound must also never exceed what the model recovery pass can deliver,
route could hand back more assigned rows than the model pass ever recovers. or the route would advertise a budget nothing can fill.
""" """
assert routes.CLI_PROJECT_ASSIGNED_CAP == models.PROJECT_ASSIGNED_CLI_LIMIT == 200
assert ( assert (
routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING routes.CLI_PROJECT_ASSIGNED_CAP
== models.PROJECT_ASSIGNED_CLI_SCAN_CEILING <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING
) )