fix(sessions): re-query a starved project when its own window was binding
The project-scoped recovery pass asked for the global window's exhaustion signal but never for its own. A scoped query carries a raw candidate window of its own (limit * 8, re-widened to limit * 32), and compression segments plus the post-projection visibility filters are spent from that window BEFORE the logical slice. A lineage-heavy project therefore came back short with its window fully consumed while older assigned conversations of the same project still waited behind it, and the loop advanced to the next project — leaving that project under-delivered on every rebuild. The scoped query now asks for `return_window_exhaustion`, and a binding window buys exactly one re-query at the widest limit this pass is allowed to scan (`query_limit`). The retry is bounded to one extra query per starved project and cannot out-read the pass's own scan budget. The regression test builds a starved project whose scoped raw window is filled by its own compression segments, with an older conversation of the same project just behind the window. Against the previous head that conversation was lost — the project recovered only its chain tip — while the new head recovers it and pays exactly two scoped queries (limits 4 then 8: one widening step, inside the ceiling). Verified red without the fix and green with it; the full file is 73 passed, and the sibling suites this branch touches (test_issue2628_cli_sessions_perf, test_issue4842_cron_projection_perf) bring the run to 86 passed.
This commit is contained in:
1 parent
6411012e9b
commit
7e56aaae07
2 files changed
+122
-8
No files matched your search
+40
-8
@@ -8758,14 +8758,46 @@ def _load_cli_sessions_uncached(
|
||||
remaining = effective_limit - kept_per_project.get(project_id, 0)
|
||||
if remaining <= 0:
|
||||
continue
|
||||
_spend_assigned_rows(read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=remaining,
|
||||
log=logger,
|
||||
exclude_sources=interactive_excluded,
|
||||
project_assignment='assigned',
|
||||
project_ids=(project_id,),
|
||||
))
|
||||
# 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 at the widest
|
||||
# limit this pass is allowed to scan, so the retry is
|
||||
# bounded to one extra query per starved project and
|
||||
# cannot out-read the pass's own scan budget.
|
||||
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
|
||||
scoped_limit = query_limit
|
||||
except Exception:
|
||||
logger.debug("Project-assigned CLI recovery pass failed", exc_info=True)
|
||||
|
||||
|
||||
@@ -765,6 +765,88 @@ def test_short_global_projection_still_refills_a_starved_project(
|
||||
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
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user