From 6411012e9bfb46efa25cf20fa601f09f3e6222f9 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Mon, 24 Aug 2026 01:09:49 -0300 Subject: [PATCH] test(sessions): pin the refill's cost, not just its guarantee The existing tests all assert what the window DELIVERS, which the ceiling read satisfies too -- so the widening could regress back to a 200-row read for a 20-row window without a single failure. Two pins on the cost itself: * newest 40 of 400 moved from the WebUI: query widths exactly [40, 60] and 60 rows carried, where the degenerate proportional estimate read the full 200; * 8 moves clustered at the bottom edge of a 200-conversation window: exactly [21, 28] and 28 rows, where the crawling estimate spent all four queries and carried 200 rows / 192 of them unassigned. Both fail on the previous widening, so they are pins rather than guards. The over-read note on test_sidecar_moves_below_the_window_still_deliver_the_full_window is corrected the same way the code comment is: the surplus is bounded by the scan ceiling, not by "a few spare conversations". --- ...test_project_assigned_cli_session_limit.py | 112 +++++++++++++++++- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/tests/test_project_assigned_cli_session_limit.py b/tests/test_project_assigned_cli_session_limit.py index fd266cef..16ee626b 100755 --- a/tests/test_project_assigned_cli_session_limit.py +++ b/tests/test_project_assigned_cli_session_limit.py @@ -1328,10 +1328,14 @@ def test_sidecar_moves_below_the_window_still_deliver_the_full_window( # 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: the proportional widening can over-read when the - # moved conversations cluster at the top of the window (it infers the moved - # share from what it has seen), and reading a few spare conversations is - # cheaper than another query. The sidebar cap below trims the surplus. + # 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 @@ -1484,6 +1488,106 @@ def test_unassigned_refill_query_count_is_bounded( 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 ):