fix(sessions): refill the unassigned window when the mixed pass under-delivers

Recovery pass 2 was gated on `first_pass_count >= unassigned_target`, reading a
short first pass as "the database has nothing left". It can also mean the mixed
pass ran out of RAW candidate window: compression segments are spent from it
before the logical slice, so a lineage-heavy profile gets fewer conversations
than it asked for and the unassigned quota stays short. Also run the refill when
assigned conversations inside the window displaced unassigned candidates, which
is the case a narrower unassigned-only query can actually repair. A profile with
no assignments and a small state.db still issues no extra query.

While here, spend the per-project assigned budget through `_merge_state_row`'s
return value instead of pre-incrementing, so the budget counts logical
conversations the way PROJECT_ASSIGNED_CLI_LIMIT documents, and seed it from the
assigned rows already inside the window so the bound stays a total rather than
one bound stacked on another. That accounting is behaviour-neutral today (the
assigned query is newest-first, so window rows are always the first rows it
returns) — it makes the documented contract hold by construction rather than by
that ordering accident, and removes an unused return value.
This commit is contained in:
Rodrigo Gomes da Silva committed 2026-09-24 11:03:46 -03:00
1 parent 8211acdd84
commit 05816be3c2
2 files changed
+78 -14

No files matched your search

@@ -716,6 +716,43 @@ def test_schema_without_project_id_retains_prior_behavior(fake_hermes_home, tmp_
]
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
# ── Rebase guard: upstream's kanban pass shares the system-chip helper ────────