fix(sessions): refill starved projects when projection under-delivers

The GROUP BY probe that finds the projects a global assigned query starved
only fired when that query came back logically FULL. A query can instead
stay short because its bounded raw candidate window was entirely consumed
by compression segments and hidden rows, while older assigned
conversations still exist below it. Read as "the database has nothing more
to give", that shortfall skipped the probe and left the quiet projects
unreachable.

Have read_importable_agent_session_rows report whether the final raw
candidate window was exhausted while its logical projection stayed short
(opt-in via return_window_exhaustion, so the list API is unchanged for
every other caller), and gate the probe on saturation OR that exhaustion.
A short result from an unfilled raw window has genuinely seen every
candidate, so the common one-global-query fast path still pays no probe.
This commit is contained in:
Rodrigo Gomes da Silva committed 2026-09-24 11:03:46 -03:00
1 parent 01fb394001
commit b5ccf56c53
3 files changed
+107 -26

No files matched your search

@@ -559,6 +559,56 @@ def test_no_followup_queries_when_the_global_window_is_not_saturated(
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_saturated_window_pays_one_query_per_starved_project(
fake_hermes_home, tmp_path, monkeypatch
):