The route cap bounded project-assigned CLI rows PER PROJECT at 200, which is not a bound on the payload: 200 rows times N projects grows with the project count. The review's own reproduction still held — 1,000 assigned conversations spread over 5 projects returned all 1,000, because no project exceeded its own 200 (#6659 review finding 1). Imported WebUI sidecars from all_sessions() never pass through a state.db cap, so this merged pass is the only place that can bound them. Give each project an EQUAL share of a global CLI_PROJECT_ASSIGNED_SCAN_CEILING (ceiling // projects, never below 1) once enough projects exist to make that share tighter than the per-project cap. Same shape as the model-side share of PROJECT_ASSIGNED_CLI_SCAN_CEILING, and for the same reason a flat global cap is unusable here: it would leave every project past it permanently unreachable on each rebuild (greptile P1). Profiles with a few projects keep the full 200, so the common case is unchanged. The ceiling is duplicated as a literal because api.models is imported below this cap in api/routes.py; a test pins the two equal, mirroring test_background_source_exclusion_literal_matches_the_constant.
1572 lines
61 KiB
Python
Executable File
1572 lines
61 KiB
Python
Executable File
"""Regression: project-assigned CLI sessions must survive the recent-session cap.
|
|
|
|
Two independent 20-session caps used to hide older project-assigned CLI/TUI
|
|
sessions while their project chip still claimed them. Recovering them must not
|
|
swing to the opposite failure, so these tests pin BOTH edges of the contract:
|
|
|
|
* an assigned conversation older than the recent window is still reachable
|
|
(the original bug), including past the 200-conversation mark when the
|
|
assignments are spread over several projects;
|
|
* one busy project cannot starve the others: a per-project budget applied to a
|
|
single global recency window is not a per-project bound, so a saturated window
|
|
is followed by project-scoped, per-project-budgeted queries (greptile P1);
|
|
* 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);
|
|
* the recovered set is BOUNDED — per project, counting logical conversations —
|
|
including assigned rows that arrive as imported WebUI sidecars and therefore
|
|
never pass through any state.db cap;
|
|
* assigned rows do not spend the unassigned sidebar quota: 20 unique unassigned
|
|
logical conversations survive after lineage/sidecar dedup;
|
|
* a deleted or cross-profile ``project_id`` cannot hide a session — it resolves
|
|
to "unassigned" before either cap runs, instead of becoming a ``default_hidden``
|
|
row with no chip left to reveal it;
|
|
* a project assignment recorded on a newer EMPTY compression continuation is
|
|
applied to the lineage even when the root is the freshest importable segment.
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from api import agent_sessions, models, routes
|
|
|
|
BASE_TS = 1700000000.0
|
|
|
|
|
|
def _session(
|
|
sid,
|
|
started_at,
|
|
*,
|
|
project_id=None,
|
|
parent=None,
|
|
ended_at=None,
|
|
end_reason=None,
|
|
source="cli",
|
|
messages=1,
|
|
title=None,
|
|
):
|
|
"""One state.db ``sessions`` row. Titled by default so the row is visible."""
|
|
return {
|
|
"id": sid,
|
|
"title": sid if title is None else title,
|
|
"model": "gpt-x",
|
|
"message_count": messages,
|
|
"started_at": started_at,
|
|
"source": source,
|
|
"project_id": project_id,
|
|
"parent_session_id": parent,
|
|
"ended_at": ended_at,
|
|
"end_reason": end_reason,
|
|
"messages": messages,
|
|
}
|
|
|
|
|
|
def _write_state_db(db_path, rows, *, lineage_columns=True):
|
|
"""Create a minimal agent state.db from ``_session()`` rows."""
|
|
columns = [
|
|
"id", "title", "model", "message_count", "started_at", "source", "project_id",
|
|
]
|
|
lineage_ddl = ""
|
|
if lineage_columns:
|
|
columns += ["parent_session_id", "ended_at", "end_reason"]
|
|
lineage_ddl = ", parent_session_id TEXT, ended_at REAL, end_reason TEXT"
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute(
|
|
"CREATE TABLE sessions ("
|
|
"id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER, "
|
|
f"started_at REAL, source TEXT, project_id TEXT{lineage_ddl})"
|
|
)
|
|
conn.execute(
|
|
"CREATE TABLE messages ("
|
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, timestamp REAL, role TEXT)"
|
|
)
|
|
placeholders = ", ".join("?" for _ in columns)
|
|
for row in rows:
|
|
conn.execute(
|
|
f"INSERT INTO sessions ({', '.join(columns)}) VALUES ({placeholders})",
|
|
[row.get(column) for column in columns],
|
|
)
|
|
for index in range(int(row.get("messages") or 0)):
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, timestamp, role) VALUES (?, ?, ?)",
|
|
(row["id"], float(row["started_at"]) + 0.5 + index, "user"),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _lineage(prefix, start_ts, segments, *, project_id=None, tip_project_id=None,
|
|
step=10.0, messages=1):
|
|
"""A compression chain: ``segments`` rows, only the last one still open."""
|
|
rows = []
|
|
for index in range(segments):
|
|
last = index == segments - 1
|
|
started_at = start_ts + index * step
|
|
rows.append(_session(
|
|
f"{prefix}-seg{index}",
|
|
started_at,
|
|
project_id=(
|
|
tip_project_id if last and tip_project_id is not None
|
|
else project_id if index == 0
|
|
else None
|
|
),
|
|
parent=None if index == 0 else f"{prefix}-seg{index - 1}",
|
|
ended_at=None if last else started_at + step / 2,
|
|
end_reason=None if last else "compression",
|
|
messages=messages,
|
|
))
|
|
return rows
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_hermes_home(tmp_path, monkeypatch):
|
|
"""Point get_cli_sessions() at a temporary HERMES_HOME + projects.json."""
|
|
home = tmp_path / "hermes"
|
|
home.mkdir()
|
|
|
|
import api.config as cfg
|
|
import api.profiles as profiles
|
|
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home)
|
|
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: None)
|
|
# Keep root-profile aliasing hermetic: the real _is_root_profile falls back
|
|
# to list_profiles_api(), which shells out to the agent CLI.
|
|
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name == "default")
|
|
|
|
projects_file = tmp_path / "projects.json"
|
|
monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file)
|
|
monkeypatch.setattr(models, "PROJECTS_FILE", projects_file)
|
|
monkeypatch.setattr(models, "_projects_migrated", True)
|
|
projects_file.write_text("[]", encoding="utf-8")
|
|
|
|
models.clear_cli_sessions_cache()
|
|
yield home
|
|
models.clear_cli_sessions_cache()
|
|
|
|
|
|
def _register_projects(tmp_path, *project_ids, profile="default"):
|
|
"""Write ``project_ids`` into the projects.json the loader resolves against."""
|
|
existing = json.loads((tmp_path / "projects.json").read_text(encoding="utf-8"))
|
|
existing.extend(
|
|
{
|
|
"project_id": project_id,
|
|
"name": f"Project {project_id}",
|
|
"color": "#6366f1",
|
|
"profile": profile,
|
|
"created_at": 1.0,
|
|
}
|
|
for project_id in project_ids
|
|
)
|
|
(tmp_path / "projects.json").write_text(json.dumps(existing), encoding="utf-8")
|
|
models.clear_cli_sessions_cache()
|
|
|
|
|
|
# ── The original bug: an assigned session older than the recent window ────────
|
|
|
|
|
|
def test_project_assigned_cli_session_survives_recent_session_limit(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-123")
|
|
|
|
rows = [
|
|
_session(f"recent-{index:02d}", BASE_TS + 100 + index)
|
|
for index in range(25)
|
|
]
|
|
rows.append(_session("older-assigned", BASE_TS, project_id="project-123"))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
by_id = {session["session_id"]: session for session in sessions}
|
|
|
|
assert "older-assigned" in by_id
|
|
assert by_id["older-assigned"]["project_id"] == "project-123"
|
|
assert [session["session_id"] for session in sessions].count("older-assigned") == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("root_project_id", "tip_project_id"),
|
|
[
|
|
("project-123", None),
|
|
(None, "project-123"),
|
|
],
|
|
)
|
|
def test_project_assigned_compression_lineage_is_returned_once(
|
|
fake_hermes_home,
|
|
tmp_path,
|
|
monkeypatch,
|
|
root_project_id,
|
|
tip_project_id,
|
|
):
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-123")
|
|
|
|
rows = [
|
|
_session(
|
|
"lineage-root",
|
|
BASE_TS,
|
|
project_id=root_project_id,
|
|
ended_at=BASE_TS + 1,
|
|
end_reason="compression",
|
|
),
|
|
_session("lineage-tip", BASE_TS + 200, project_id=tip_project_id, parent="lineage-root"),
|
|
]
|
|
# Keep the tip inside the normal five-row window while pushing the root
|
|
# outside its 8x SQL candidate window. This reproduces the real two-pass
|
|
# boundary: the normal pass sees only the tip and the project pass must
|
|
# bring enough lineage context to avoid appending the root separately.
|
|
rows.extend(_session(f"newer-{index}", BASE_TS + 300 + index) for index in range(4))
|
|
rows.extend(_session(f"middle-{index}", BASE_TS + 100 + index) for index in range(56))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
lineage_rows = [
|
|
session
|
|
for session in sessions
|
|
if session["session_id"] in {"lineage-root", "lineage-tip"}
|
|
]
|
|
|
|
assert len(lineage_rows) == 1
|
|
assert lineage_rows[0]["session_id"] in {"lineage-root", "lineage-tip"}
|
|
assert lineage_rows[0]["project_id"] == "project-123"
|
|
|
|
|
|
# ── Finding 1: the recovered assigned set must stay bounded ───────────────────
|
|
|
|
|
|
def test_assigned_conversations_are_bounded_per_project(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""250 assigned conversations in one project yield the newest 200, not 250."""
|
|
monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_LIMIT", 200)
|
|
_register_projects(tmp_path, "project-a")
|
|
|
|
rows = [
|
|
_session(f"assigned-{index:04d}", BASE_TS + index, project_id="project-a")
|
|
for index in range(250)
|
|
]
|
|
rows.extend(
|
|
_session(f"recent-{index:02d}", BASE_TS + 1000 + index) for index in range(25)
|
|
)
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
assigned = [s for s in sessions if s["project_id"] == "project-a"]
|
|
assigned_ids = {s["session_id"] for s in assigned}
|
|
|
|
assert len(assigned) == 200
|
|
# The bound drops the OLDEST assigned conversations, never a newer one.
|
|
assert "assigned-0249" in assigned_ids
|
|
assert "assigned-0050" in assigned_ids
|
|
assert "assigned-0049" not in assigned_ids
|
|
assert "assigned-0000" not in assigned_ids
|
|
|
|
|
|
def test_route_cap_bounds_imported_sidecar_assigned_rows(fake_hermes_home):
|
|
"""Imported CLI sidecars bypass every state.db cap (review finding 1).
|
|
|
|
``all_sessions()`` rows never pass through read_importable_agent_session_rows,
|
|
so the merged payload is the only place that can bound them. 1,000 assigned
|
|
rows used to yield 1,000 returned rows.
|
|
"""
|
|
sidecars = [
|
|
{
|
|
"session_id": f"imported-{index:04d}",
|
|
"is_cli_session": True,
|
|
"project_id": "project-prime",
|
|
}
|
|
for index in range(1000)
|
|
]
|
|
|
|
kept = routes._cap_recent_cli_sessions(sidecars)
|
|
|
|
assert len(kept) == routes.CLI_PROJECT_ASSIGNED_CAP
|
|
assert kept[0]["session_id"] == "imported-0000"
|
|
# The recent window still renders normally; only the overflow is chip-only.
|
|
visible = [row for row in kept if not row.get("default_hidden")]
|
|
assert len(visible) == routes.CLI_VISIBLE_SESSION_CAP
|
|
# Capping never mutates the caller's rows.
|
|
assert not any("default_hidden" in row for row in sidecars)
|
|
|
|
|
|
def test_route_cap_bounds_each_project_independently(fake_hermes_home):
|
|
"""A busy project must not evict another project's history (greptile P1).
|
|
|
|
The bound is per project, so a profile with more than
|
|
``CLI_PROJECT_ASSIGNED_CAP`` assigned conversations in TOTAL keeps them all.
|
|
"""
|
|
rows = []
|
|
for project in ("project-a", "project-b"):
|
|
rows.extend(
|
|
{
|
|
"session_id": f"{project}-{index:04d}",
|
|
"is_cli_session": True,
|
|
"project_id": project,
|
|
}
|
|
for index in range(250)
|
|
)
|
|
|
|
kept = routes._cap_recent_cli_sessions(rows)
|
|
|
|
counts: dict[str, int] = {}
|
|
for row in kept:
|
|
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,
|
|
}
|
|
assert len(kept) > routes.CLI_PROJECT_ASSIGNED_CAP
|
|
|
|
|
|
def test_route_cap_bounds_the_whole_merged_assigned_set(fake_hermes_home):
|
|
"""A per-project bound alone is not a bound on the payload (finding 1).
|
|
|
|
``CLI_PROJECT_ASSIGNED_CAP`` rows times N projects grows with the project
|
|
count: 1,000 assigned conversations spread over 5 projects reproduced the
|
|
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
|
|
per_project = 200
|
|
rows = []
|
|
for project in range(project_count):
|
|
rows.extend(
|
|
{
|
|
"session_id": f"project-{project:02d}-{index:04d}",
|
|
"is_cli_session": True,
|
|
"project_id": f"project-{project:02d}",
|
|
}
|
|
for index in range(per_project)
|
|
)
|
|
assert len(rows) > routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
|
|
|
|
kept = routes._cap_recent_cli_sessions(rows)
|
|
|
|
assert len(kept) <= routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
|
|
# Bounded by an EQUAL share, not by dropping whole projects: every project
|
|
# 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 set(counts.values()) == {share}
|
|
# The share only bites once it is tighter than the per-project cap.
|
|
assert share < routes.CLI_PROJECT_ASSIGNED_CAP
|
|
|
|
|
|
def test_route_cap_share_does_not_shrink_below_the_per_project_cap(fake_hermes_home):
|
|
"""Few projects must keep the full 200 — the ceiling is a ceiling, not a quota.
|
|
|
|
Negative control for the test above: with the ceiling comfortably wider than
|
|
``project_count * CLI_PROJECT_ASSIGNED_CAP`` the share is inert, so the
|
|
bounding above is attributable to the ceiling and not to a blanket shrink.
|
|
"""
|
|
rows = []
|
|
for project in ("project-a", "project-b"):
|
|
rows.extend(
|
|
{
|
|
"session_id": f"{project}-{index:04d}",
|
|
"is_cli_session": True,
|
|
"project_id": project,
|
|
}
|
|
for index in range(250)
|
|
)
|
|
|
|
kept = routes._cap_recent_cli_sessions(rows)
|
|
|
|
counts: dict[str, int] = {}
|
|
for row in kept:
|
|
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():
|
|
"""The route literal must not drift from the model-side scan ceiling.
|
|
|
|
Same reasoning as ``test_background_source_exclusion_literal_matches_the_constant``:
|
|
the value is duplicated because ``api.models`` is imported below this cap in
|
|
``api/routes.py``, so only a test can keep the two honest. If they diverge the
|
|
route could hand back more assigned rows than the model pass ever recovers.
|
|
"""
|
|
assert (
|
|
routes.CLI_PROJECT_ASSIGNED_SCAN_CEILING
|
|
== models.PROJECT_ASSIGNED_CLI_SCAN_CEILING
|
|
)
|
|
|
|
|
|
def test_more_than_two_hundred_assigned_conversations_survive_across_projects(
|
|
fake_hermes_home, tmp_path
|
|
):
|
|
"""The model pass budgets per project too, not a single global 200."""
|
|
_register_projects(tmp_path, "project-a", "project-b")
|
|
|
|
rows = [
|
|
_session(f"a-{index:04d}", BASE_TS + index, project_id="project-a")
|
|
for index in range(150)
|
|
]
|
|
rows.extend(
|
|
_session(f"b-{index:04d}", BASE_TS + 500 + index, project_id="project-b")
|
|
for index in range(150)
|
|
)
|
|
rows.extend(
|
|
_session(f"recent-{index:02d}", BASE_TS + 2000 + index) for index in range(25)
|
|
)
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
counts: dict[str, int] = {}
|
|
for session in sessions:
|
|
if session["project_id"]:
|
|
counts[session["project_id"]] = counts.get(session["project_id"], 0) + 1
|
|
|
|
# 300 assigned conversations total — a global 200 bound would silently drop
|
|
# the 100 oldest, all of them project-a's.
|
|
assert counts == {"project-a": 150, "project-b": 150}
|
|
|
|
|
|
def test_model_bounds_a_skewed_project_inside_a_wider_scan_window(
|
|
fake_hermes_home, tmp_path
|
|
):
|
|
"""The per-project budget, not the query LIMIT, is what bounds a project.
|
|
|
|
The recovery query asks for ``PROJECT_ASSIGNED_CLI_LIMIT * len(projects)``
|
|
conversations, so with two registered projects its window is 400 — wide
|
|
enough to hand back all 250 of project-a's on its own. Only the per-project
|
|
accounting keeps project-a at 200, so a skewed distribution is the case that
|
|
actually exercises it (a single-project profile has query_limit == the bound
|
|
and would pass either way).
|
|
"""
|
|
_register_projects(tmp_path, "project-a", "project-b")
|
|
|
|
rows = [
|
|
_session(f"a-{index:04d}", BASE_TS + index, project_id="project-a")
|
|
for index in range(250)
|
|
]
|
|
rows.extend(
|
|
_session(f"b-{index:04d}", BASE_TS + 5000 + index, project_id="project-b")
|
|
for index in range(5)
|
|
)
|
|
rows.extend(
|
|
_session(f"recent-{index:02d}", BASE_TS + 9000 + index) for index in range(25)
|
|
)
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
counts: dict[str, int] = {}
|
|
for session in sessions:
|
|
if session["project_id"]:
|
|
counts[session["project_id"]] = counts.get(session["project_id"], 0) + 1
|
|
assigned_ids = {s["session_id"] for s in sessions if s["project_id"] == "project-a"}
|
|
|
|
assert counts == {"project-a": models.PROJECT_ASSIGNED_CLI_LIMIT, "project-b": 5}
|
|
# The budget spends newest-first, so the dropped rows are the oldest 50.
|
|
assert "a-0249" in assigned_ids
|
|
assert "a-0050" in assigned_ids
|
|
assert "a-0049" not in assigned_ids
|
|
# The smaller project is untouched by its neighbour's overflow.
|
|
assert {s["session_id"] for s in sessions if s["project_id"] == "project-b"} == {
|
|
f"b-{index:04d}" for index in range(5)
|
|
}
|
|
|
|
|
|
# ── greptile P1: a busy project must not starve the quieter ones ──────────────
|
|
|
|
|
|
def _assigned_counts(sessions):
|
|
counts: dict[str, int] = {}
|
|
for session in sessions:
|
|
if session["project_id"]:
|
|
counts[session["project_id"]] = counts.get(session["project_id"], 0) + 1
|
|
return counts
|
|
|
|
|
|
def test_busy_project_does_not_starve_quieter_projects(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""The per-project BUDGET is not enough while the QUERY is one global window.
|
|
|
|
The recovery pass asked for ``limit * len(projects)`` newest assigned
|
|
conversations in a single recency-ordered query and only then applied the
|
|
per-project budget. When one project owns that whole window, every other
|
|
project's assigned conversations are never even considered: their chips show
|
|
nothing at all. Reproduced with a scaled budget (3) so the fixture stays
|
|
small; ``test_busy_project_starvation_at_production_limits`` pins the same
|
|
behaviour at the real 200.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-busy", "project-quiet-a", "project-quiet-b")
|
|
|
|
rows = [
|
|
_session("quiet-a-old", BASE_TS, project_id="project-quiet-a"),
|
|
_session("quiet-b-old", BASE_TS + 1, project_id="project-quiet-b"),
|
|
]
|
|
# 12 newer assigned conversations in one project fill the 3 * 3 = 9 window.
|
|
rows.extend(
|
|
_session(f"busy-{index:03d}", BASE_TS + 100 + index, project_id="project-busy")
|
|
for index in range(12)
|
|
)
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models._load_cli_sessions_uncached(
|
|
fake_hermes_home,
|
|
fake_hermes_home / "state.db",
|
|
"default",
|
|
project_assigned_limit=3,
|
|
)
|
|
|
|
# Was {'project-busy': 3}: both quiet projects were completely absent.
|
|
assert _assigned_counts(sessions) == {
|
|
"project-busy": 3,
|
|
"project-quiet-a": 1,
|
|
"project-quiet-b": 1,
|
|
}
|
|
# The busy project still gets its newest, and is still bounded.
|
|
assert {s["session_id"] for s in sessions if s["project_id"] == "project-busy"} == {
|
|
"busy-011", "busy-010", "busy-009",
|
|
}
|
|
|
|
|
|
def test_busy_project_starvation_at_production_limits(fake_hermes_home, tmp_path):
|
|
"""The same starvation with the real PROJECT_ASSIGNED_CLI_LIMIT (200).
|
|
|
|
Two registered projects give the old global query a 400-conversation window;
|
|
400 newer conversations in one project consumed all of it, so the quiet
|
|
project's single assigned conversation was unreachable.
|
|
"""
|
|
_register_projects(tmp_path, "project-busy", "project-quiet")
|
|
|
|
rows = [_session("quiet-old", BASE_TS, project_id="project-quiet")]
|
|
rows.extend(
|
|
_session(f"busy-{index:04d}", BASE_TS + 100 + index, project_id="project-busy")
|
|
for index in range(models.PROJECT_ASSIGNED_CLI_LIMIT * 2)
|
|
)
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + 5000 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
counts = _assigned_counts(models.get_cli_sessions())
|
|
|
|
# Was {'project-busy': 200}.
|
|
assert counts == {
|
|
"project-busy": models.PROJECT_ASSIGNED_CLI_LIMIT,
|
|
"project-quiet": 1,
|
|
}
|
|
|
|
|
|
def test_starved_project_recovers_its_whole_compression_lineage(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""The project-scoped follow-up query is still lineage-keyed.
|
|
|
|
``project_ids`` narrows only the recursive CTE's SEED, so a starved project's
|
|
conversation still arrives as ONE row even when its assignment sits on the
|
|
root of a compression chain.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-busy", "project-quiet")
|
|
|
|
rows = _lineage("quiet-chain", BASE_TS, 3, project_id="project-quiet")
|
|
rows.extend(
|
|
_session(f"busy-{index:03d}", BASE_TS + 100 + index, project_id="project-busy")
|
|
for index in range(12)
|
|
)
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models._load_cli_sessions_uncached(
|
|
fake_hermes_home,
|
|
fake_hermes_home / "state.db",
|
|
"default",
|
|
project_assigned_limit=2,
|
|
)
|
|
|
|
quiet = [s for s in sessions if s["project_id"] == "project-quiet"]
|
|
assert len(quiet) == 1, [s["session_id"] for s in quiet]
|
|
assert quiet[0]["session_id"] in {f"quiet-chain-seg{index}" for index in range(3)}
|
|
|
|
|
|
def test_no_followup_queries_when_the_global_window_is_not_saturated(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""A short global window already saw everything, so it must cost 1 query.
|
|
|
|
The starvation follow-up is gated on saturation. Without that gate every
|
|
sidebar build on every profile with projects would pay a GROUP BY probe plus
|
|
a query per project.
|
|
"""
|
|
_register_projects(tmp_path, "project-a", "project-b", "project-c")
|
|
|
|
rows = [
|
|
_session("a-1", BASE_TS, project_id="project-a"),
|
|
_session("b-1", BASE_TS + 1, project_id="project-b"),
|
|
]
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
assigned_queries = []
|
|
real_reader = models.read_importable_agent_session_rows
|
|
|
|
def _counting_reader(*args, **kwargs):
|
|
if kwargs.get("project_assignment") == "assigned":
|
|
assigned_queries.append(kwargs.get("project_ids"))
|
|
return real_reader(*args, **kwargs)
|
|
|
|
probes = []
|
|
real_probe = models.read_assigned_project_row_counts
|
|
|
|
def _counting_probe(*args, **kwargs):
|
|
probes.append(args)
|
|
return real_probe(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader)
|
|
monkeypatch.setattr(models, "read_assigned_project_row_counts", _counting_probe)
|
|
|
|
sessions = models._load_cli_sessions_uncached(
|
|
fake_hermes_home,
|
|
fake_hermes_home / "state.db",
|
|
"default",
|
|
project_assigned_limit=3,
|
|
)
|
|
|
|
assert _assigned_counts(sessions) == {"project-a": 1, "project-b": 1}
|
|
assert assigned_queries == [None], "one global assigned query, no per-project ones"
|
|
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
|
|
):
|
|
"""Pin the cost of the worst case: probe + one query per starved project.
|
|
|
|
The ceiling is forced to 20 over 5 projects (a share of 4) so saturation is
|
|
reachable with a small fixture. Every project ends up represented, the
|
|
follow-up queries are project-scoped and each carries only that project's
|
|
remaining budget, and the recovered total stays under the ceiling.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 20)
|
|
quiet = [f"quiet-{index}" for index in range(4)]
|
|
_register_projects(tmp_path, "project-busy", *quiet)
|
|
|
|
rows = [
|
|
_session(f"{project}-old", BASE_TS + index, project_id=project)
|
|
for index, project in enumerate(quiet)
|
|
]
|
|
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"], kwargs.get("limit")))
|
|
return real_reader(*args, **kwargs)
|
|
|
|
probes = []
|
|
real_probe = models.read_assigned_project_row_counts
|
|
|
|
def _counting_probe(*args, **kwargs):
|
|
probes.append(args)
|
|
return real_probe(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(models, "read_importable_agent_session_rows", _counting_reader)
|
|
monkeypatch.setattr(models, "read_assigned_project_row_counts", _counting_probe)
|
|
|
|
counts = _assigned_counts(models._load_cli_sessions_uncached(
|
|
fake_hermes_home,
|
|
fake_hermes_home / "state.db",
|
|
"default",
|
|
))
|
|
|
|
assert len(probes) == 1, "exactly one GROUP BY probe per saturated build"
|
|
assert scoped_queries == [(("quiet-0",), 4), (("quiet-1",), 4),
|
|
(("quiet-2",), 4), (("quiet-3",), 4)]
|
|
assert counts == {"project-busy": 4, "quiet-0": 1, "quiet-1": 1,
|
|
"quiet-2": 1, "quiet-3": 1}
|
|
assert sum(counts.values()) <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING
|
|
|
|
|
|
def test_followup_serves_every_starved_project_neediest_first(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""Every starved project gets its follow-up, in neediest-first order.
|
|
|
|
The first version of the follow-up was capped at a fixed number of projects
|
|
per build; with the cap forced to 1, only the lower-numbered project id was
|
|
served and the other stayed unreachable on every rebuild (greptile P1 on
|
|
#6659). The cap is gone: both starved projects are served, in the same
|
|
deterministic order the cap used to cut.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-busy", "project-quiet-a", "project-quiet-b")
|
|
|
|
rows = [
|
|
_session("quiet-a-old", BASE_TS, project_id="project-quiet-a"),
|
|
_session("quiet-b-old", BASE_TS + 1, project_id="project-quiet-b"),
|
|
]
|
|
rows.extend(
|
|
_session(f"busy-{index:03d}", BASE_TS + 100 + index, project_id="project-busy")
|
|
for index in range(12)
|
|
)
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + 500 + 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")))
|
|
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=3,
|
|
)
|
|
|
|
# Neediest first: both projects are equally starved, so id order wins.
|
|
assert scoped_queries == [("project-quiet-a", 3), ("project-quiet-b", 3)]
|
|
assert _assigned_counts(sessions) == {
|
|
"project-busy": 3,
|
|
"project-quiet-a": 1,
|
|
"project-quiet-b": 1,
|
|
}
|
|
|
|
|
|
def test_every_starved_project_is_served_past_the_old_refill_cap(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""The refill has no project-count cap: all 39 starved projects are served.
|
|
|
|
greptile P1 on #6659: with more starved projects than the old fixed refill
|
|
cap (32), only the first 32 were served and every later project stayed
|
|
unreachable on each rebuild — the recovery is re-derived from the database
|
|
on every build, so the same 32 won every time. Ceiling 40 over 40 projects
|
|
is a share of 1, one busy project saturates the global window, and all 39
|
|
quieter projects must still get their scoped follow-up.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 40)
|
|
quiet = [f"quiet-{index:02d}" for index in range(39)]
|
|
_register_projects(tmp_path, "project-busy", *quiet)
|
|
|
|
rows = [
|
|
_session(f"{project}-old", BASE_TS + index, project_id=project)
|
|
for index, project in enumerate(quiet)
|
|
]
|
|
rows.extend(
|
|
_session(f"busy-{index:03d}", BASE_TS + 1000 + index, project_id="project-busy")
|
|
for index in range(40)
|
|
)
|
|
_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])
|
|
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",
|
|
)
|
|
|
|
counts = _assigned_counts(sessions)
|
|
assert set(counts) == {"project-busy", *quiet}
|
|
assert len(scoped_queries) == len(quiet)
|
|
assert set(scoped_queries) == set(quiet)
|
|
assert counts == {"project-busy": 5, **{project: 1 for project in quiet}}
|
|
|
|
|
|
def test_scan_ceiling_is_shared_equally_between_projects(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""Past the ceiling every project's share shrinks; none is left with zero.
|
|
|
|
Ceiling 6 over 3 projects is a share of 2 even though the requested budget is
|
|
5, so the recovered payload stays under the ceiling AND every project is
|
|
represented — the old code let the newest project take 5 of the 6.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
monkeypatch.setattr(models, "PROJECT_ASSIGNED_CLI_SCAN_CEILING", 6)
|
|
_register_projects(tmp_path, "project-a", "project-b", "project-c")
|
|
|
|
rows = []
|
|
for offset, project in enumerate(("project-a", "project-b", "project-c")):
|
|
rows.extend(
|
|
_session(
|
|
f"{project}-{index}",
|
|
BASE_TS + offset * 100 + index,
|
|
project_id=project,
|
|
)
|
|
for index in range(4)
|
|
)
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + 900 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models._load_cli_sessions_uncached(
|
|
fake_hermes_home,
|
|
fake_hermes_home / "state.db",
|
|
"default",
|
|
project_assigned_limit=5,
|
|
)
|
|
|
|
counts = _assigned_counts(sessions)
|
|
assert counts == {"project-a": 2, "project-b": 2, "project-c": 2}
|
|
assert sum(counts.values()) <= models.PROJECT_ASSIGNED_CLI_SCAN_CEILING
|
|
|
|
|
|
# ── The reader-level contract the per-project follow-up relies on ─────────────
|
|
|
|
|
|
def test_project_ids_narrows_the_assigned_query_to_one_project(tmp_path):
|
|
"""``project_ids`` seeds the lineage CTE with one project's rows only."""
|
|
db_path = tmp_path / "state.db"
|
|
rows = [
|
|
_session("a-1", BASE_TS, project_id="project-a"),
|
|
_session("b-1", BASE_TS + 1, project_id="project-b"),
|
|
_session("plain", BASE_TS + 2),
|
|
]
|
|
rows.extend(_lineage("b-chain", BASE_TS + 10, 3, project_id="project-b"))
|
|
_write_state_db(db_path, rows)
|
|
|
|
def _ids(**kwargs):
|
|
return sorted(
|
|
row["id"]
|
|
for row in agent_sessions.read_importable_agent_session_rows(
|
|
db_path, limit=50, exclude_sources=None, **kwargs
|
|
)
|
|
)
|
|
|
|
assert _ids(project_assignment="assigned") == ["a-1", "b-1", "b-chain-seg2"]
|
|
assert _ids(project_assignment="assigned", project_ids=("project-b",)) == [
|
|
"b-1", "b-chain-seg2",
|
|
]
|
|
assert _ids(project_assignment="assigned", project_ids=("project-a",)) == ["a-1"]
|
|
# Unknown ids are not an error, they simply select nothing.
|
|
assert _ids(project_assignment="assigned", project_ids=("nope",)) == []
|
|
|
|
|
|
def test_project_ids_requires_the_assigned_filter(tmp_path):
|
|
"""A silently unnarrowed query is exactly the starvable one — refuse it."""
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(db_path, [_session("a-1", BASE_TS, project_id="project-a")])
|
|
|
|
for assignment in (None, "unassigned"):
|
|
with pytest.raises(ValueError, match="project_ids requires"):
|
|
agent_sessions.read_importable_agent_session_rows(
|
|
db_path, project_assignment=assignment, project_ids=("project-a",)
|
|
)
|
|
|
|
|
|
def test_empty_project_ids_selects_nothing(tmp_path):
|
|
"""``project_ids=()`` must not degrade to "every assigned conversation"."""
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(db_path, [_session("a-1", BASE_TS, project_id="project-a")])
|
|
|
|
assert agent_sessions.read_importable_agent_session_rows(
|
|
db_path, exclude_sources=None, project_assignment="assigned", project_ids=()
|
|
) == []
|
|
|
|
|
|
def test_project_ids_narrows_on_a_schema_without_lineage_columns(tmp_path):
|
|
"""The no-parent_session_id schema takes the plain ``project_id IN`` path."""
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(
|
|
db_path,
|
|
[
|
|
_session("a-1", BASE_TS, project_id="project-a"),
|
|
_session("b-1", BASE_TS + 1, project_id="project-b"),
|
|
],
|
|
lineage_columns=False,
|
|
)
|
|
|
|
rows = agent_sessions.read_importable_agent_session_rows(
|
|
db_path,
|
|
limit=50,
|
|
exclude_sources=None,
|
|
project_assignment="assigned",
|
|
project_ids=("project-b",),
|
|
)
|
|
|
|
assert [row["id"] for row in rows] == ["b-1"]
|
|
|
|
|
|
def test_assigned_project_row_counts_probe(tmp_path):
|
|
"""The probe counts assigned RAW rows per project and honours exclusions."""
|
|
db_path = tmp_path / "state.db"
|
|
rows = [
|
|
_session("a-1", BASE_TS, project_id="project-a"),
|
|
_session("a-2", BASE_TS + 1, project_id="project-a"),
|
|
_session("b-1", BASE_TS + 2, project_id="project-b"),
|
|
_session("b-cron", BASE_TS + 3, project_id="project-b", source="cron"),
|
|
_session("plain", BASE_TS + 4),
|
|
_session("blank", BASE_TS + 5, project_id=" "),
|
|
]
|
|
_write_state_db(db_path, rows)
|
|
|
|
assert agent_sessions.read_assigned_project_row_counts(db_path) == {
|
|
"project-a": 2, "project-b": 2,
|
|
}
|
|
assert agent_sessions.read_assigned_project_row_counts(
|
|
db_path, exclude_sources=("cron", "webhook", "kanban")
|
|
) == {"project-a": 2, "project-b": 1}
|
|
# Missing db and a schema without project_id are answered, not raised.
|
|
assert agent_sessions.read_assigned_project_row_counts(tmp_path / "nope.db") == {}
|
|
|
|
|
|
def test_assigned_project_row_counts_probe_on_schema_without_project_id(tmp_path):
|
|
db_path = tmp_path / "legacy.db"
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT)")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
assert agent_sessions.read_assigned_project_row_counts(db_path) == {}
|
|
|
|
|
|
# ── Finding 2: assigned rows must not spend the unassigned quota ──────────────
|
|
|
|
|
|
def test_route_cap_keeps_full_unassigned_window_when_assigned_rows_lead(
|
|
fake_hermes_home,
|
|
):
|
|
"""3 assigned + 20 unassigned used to yield only 17 unassigned."""
|
|
rows = [
|
|
{
|
|
"session_id": f"assigned-{index}",
|
|
"is_cli_session": True,
|
|
"project_id": "project-prime",
|
|
}
|
|
for index in range(3)
|
|
]
|
|
rows.extend(
|
|
{"session_id": f"unassigned-{index:02d}", "is_cli_session": True, "project_id": None}
|
|
for index in range(20)
|
|
)
|
|
|
|
kept = routes._cap_recent_cli_sessions(rows)
|
|
unassigned_kept = [row for row in kept if not row.get("project_id")]
|
|
|
|
assert len(unassigned_kept) == 20
|
|
assert len(kept) == 23
|
|
# All three assigned rows are inside the recent window here, so none of them
|
|
# is demoted to a chip-only row.
|
|
assert not any(row.get("default_hidden") for row in kept)
|
|
|
|
|
|
def test_route_cap_preserves_project_assigned_overflow_as_hidden(fake_hermes_home):
|
|
recent = [
|
|
{"session_id": f"recent-{index}", "is_cli_session": True, "project_id": None}
|
|
for index in range(20)
|
|
]
|
|
assigned_overflow = {
|
|
"session_id": "assigned-overflow",
|
|
"is_cli_session": True,
|
|
"project_id": "project-prime",
|
|
}
|
|
unassigned_overflow = {
|
|
"session_id": "unassigned-overflow",
|
|
"is_cli_session": True,
|
|
"project_id": None,
|
|
}
|
|
|
|
kept = routes._cap_recent_cli_sessions(
|
|
recent + [assigned_overflow, unassigned_overflow],
|
|
cli_cap=20,
|
|
)
|
|
|
|
by_id = {row["session_id"]: row for row in kept}
|
|
assert len(kept) == 21
|
|
assert "unassigned-overflow" not in by_id
|
|
assert by_id["assigned-overflow"]["default_hidden"] is True
|
|
assert "default_hidden" not in assigned_overflow
|
|
|
|
|
|
def test_model_refills_the_unassigned_window_consumed_by_assigned_rows(
|
|
fake_hermes_home, tmp_path
|
|
):
|
|
"""The first state.db pass fetches 20 rows TOTAL, so it must be refilled."""
|
|
_register_projects(tmp_path, "project-a")
|
|
|
|
rows = [
|
|
_session(f"assigned-{index}", BASE_TS + 1000 + index, project_id="project-a")
|
|
for index in range(3)
|
|
]
|
|
rows.extend(_session(f"plain-{index:02d}", BASE_TS + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
unassigned = [s for s in sessions if not s["project_id"]]
|
|
|
|
assert len(unassigned) == models.CLI_VISIBLE_SESSION_LIMIT == 20
|
|
assert len([s for s in sessions if s["project_id"] == "project-a"]) == 3
|
|
|
|
|
|
def test_unassigned_window_counts_logical_conversations_not_segments(
|
|
fake_hermes_home, tmp_path
|
|
):
|
|
"""The 20 unassigned rows must be 20 distinct conversations after dedup."""
|
|
_register_projects(tmp_path, "project-a")
|
|
|
|
rows = [
|
|
_session(f"assigned-{index}", BASE_TS + 9000 + index, project_id="project-a")
|
|
for index in range(3)
|
|
]
|
|
for index in range(25):
|
|
rows.extend(_lineage(f"chain{index:02d}", BASE_TS + index * 100, 3))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
unassigned = [s for s in sessions if not s["project_id"]]
|
|
lineage_keys = {
|
|
s.get("_lineage_root_id") or s["session_id"] for s in unassigned
|
|
}
|
|
|
|
assert len(unassigned) == 20
|
|
assert len(lineage_keys) == 20
|
|
|
|
|
|
# ── Finding 3: a stale assignment must not hide a session ─────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("project_profile", ["default", "other-profile"])
|
|
def test_unresolvable_project_id_leaves_the_session_unassigned(
|
|
fake_hermes_home, tmp_path, monkeypatch, project_profile
|
|
):
|
|
"""A deleted or cross-profile id resolves to None BEFORE either cap runs.
|
|
|
|
``project-live`` is registered for the active profile; ``project-ghost`` is
|
|
either absent from projects.json entirely (deleted) or tagged to another
|
|
profile (foreign). Both must read as "unassigned" rather than exiling the row
|
|
to a ``default_hidden`` chip that cannot exist.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-live")
|
|
if project_profile != "default":
|
|
_register_projects(tmp_path, "project-ghost", profile=project_profile)
|
|
|
|
rows = [
|
|
_session("newest-ghost", BASE_TS + 500, project_id="project-ghost"),
|
|
_session("old-ghost", BASE_TS, project_id="project-ghost"),
|
|
_session("old-live", BASE_TS + 1, project_id="project-live"),
|
|
]
|
|
rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
by_id = {session["session_id"]: session for session in sessions}
|
|
|
|
# Inside the recent window: still present, and back in "Unassigned".
|
|
assert "newest-ghost" in by_id
|
|
assert by_id["newest-ghost"]["project_id"] is None
|
|
# Outside the window: not resurrected as an orphan chip-only row. The
|
|
# live-project row in the same db proves the cap itself still recovers.
|
|
assert "old-ghost" not in by_id
|
|
assert by_id["old-live"]["project_id"] == "project-live"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"row_profile, active, expected",
|
|
[
|
|
(None, None, True),
|
|
("", None, True),
|
|
("default", None, True),
|
|
(None, "default", True),
|
|
("", "default", True),
|
|
("default", "default", True),
|
|
# Renamed root: the legacy 'default' tag and the display name are the
|
|
# same profile, in both directions.
|
|
("kinni", "default", True),
|
|
("default", "kinni", True),
|
|
(None, "kinni", True),
|
|
# Genuinely different profiles never cross.
|
|
("other", "default", False),
|
|
("default", "other", False),
|
|
("other", "other", True),
|
|
],
|
|
)
|
|
def test_profile_scoped_project_ids_uses_the_canonical_profile_match(
|
|
fake_hermes_home, tmp_path, monkeypatch, row_profile, active, expected
|
|
):
|
|
"""Ownership is decided by ``_profiles_match``, not by a local copy of it.
|
|
|
|
``api/profiles.py::_profiles_match`` documents that it exists so callers
|
|
stop duplicating this None/''/'default'/renamed-root matrix. Each row asserts
|
|
the catalog read agrees with the canonical helper cell for cell, so the two
|
|
cannot drift apart the way the duplicated body could.
|
|
"""
|
|
import api.profiles as profiles
|
|
|
|
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: active)
|
|
monkeypatch.setattr(
|
|
profiles, "_is_root_profile", lambda name: name in {"default", "kinni"}
|
|
)
|
|
(tmp_path / "projects.json").write_text(
|
|
json.dumps([
|
|
{"project_id": "p1", "name": "P1", "color": "#000",
|
|
"profile": row_profile, "created_at": 1.0},
|
|
]),
|
|
encoding="utf-8",
|
|
)
|
|
models.clear_cli_sessions_cache()
|
|
|
|
assert profiles._profiles_match(row_profile, active) is expected
|
|
assert ("p1" in models.profile_scoped_project_ids()) is expected
|
|
|
|
|
|
def test_unresolvable_project_overflow_is_not_marked_hidden(fake_hermes_home, tmp_path):
|
|
"""End of the same chain: routes must see None, so no orphan default_hidden."""
|
|
_register_projects(tmp_path, "project-live")
|
|
|
|
rows = [_session("stale-assigned", BASE_TS, project_id="project-ghost")]
|
|
rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = sorted(
|
|
models.get_cli_sessions(), key=lambda s: s["updated_at"], reverse=True
|
|
)
|
|
kept = routes._cap_recent_cli_sessions(sessions)
|
|
by_id = {row["session_id"]: row for row in kept}
|
|
|
|
assert not any(
|
|
row.get("default_hidden") and not row.get("project_id") for row in kept
|
|
)
|
|
# It is plain unassigned overflow now, so the recent cap drops it outright
|
|
# instead of parking it under a chip nothing can select.
|
|
assert "stale-assigned" not in by_id
|
|
|
|
|
|
# ── Finding 4: lineage project id on the ``tip is row`` early return ──────────
|
|
|
|
|
|
def test_lineage_project_id_kept_when_root_is_the_freshest_segment():
|
|
"""The assignment lives on a newer EMPTY continuation (agent_sessions:468).
|
|
|
|
``compression_tip()`` resolves the project id across the whole lineage but
|
|
returns the ROOT as the tip (the continuation has no messages), so the
|
|
``tip is row`` early return has to apply it too.
|
|
"""
|
|
root = _session(
|
|
"root", 100.0, ended_at=110.0, end_reason="compression", messages=4,
|
|
)
|
|
root["actual_message_count"] = 4
|
|
root["last_activity"] = 120.0
|
|
empty_tip = _session("tip", 200.0, project_id="project-123", parent="root", title="")
|
|
empty_tip["actual_message_count"] = 0
|
|
empty_tip["last_activity"] = 200.0
|
|
|
|
projected = agent_sessions._project_agent_session_rows([root, empty_tip])
|
|
|
|
assert [row["id"] for row in projected] == ["root"]
|
|
assert projected[0]["project_id"] == "project-123"
|
|
|
|
|
|
def test_assignment_on_empty_continuation_reaches_the_sidebar(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""Same case end-to-end: the recovery pass must find and keep the lineage."""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-123")
|
|
|
|
rows = [
|
|
_session("root-with-messages", BASE_TS, ended_at=BASE_TS + 1, end_reason="compression"),
|
|
_session(
|
|
"empty-continuation",
|
|
BASE_TS + 2,
|
|
project_id="project-123",
|
|
parent="root-with-messages",
|
|
messages=0,
|
|
),
|
|
]
|
|
rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
by_id = {session["session_id"]: session for session in sessions}
|
|
|
|
assert "root-with-messages" in by_id
|
|
assert by_id["root-with-messages"]["project_id"] == "project-123"
|
|
# The empty continuation is not a separately addressable conversation.
|
|
assert "empty-continuation" not in by_id
|
|
|
|
|
|
# ── greptile P1, second half: compression segments eating the raw window ──────
|
|
|
|
|
|
def test_candidate_window_widens_when_segments_consume_it(tmp_path):
|
|
"""``limit`` counts logical conversations, so a short window must re-widen.
|
|
|
|
12 ten-segment chains: the first 8x candidate window (80 raw rows) only
|
|
reaches 8 whole chains, so a request for 10 conversations came back with 8.
|
|
"""
|
|
rows = []
|
|
for index in range(12):
|
|
rows.extend(_lineage(f"chain{index:02d}", BASE_TS + index * 10_000, 10))
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(db_path, rows)
|
|
|
|
projected = agent_sessions.read_importable_agent_session_rows(
|
|
db_path, limit=10, exclude_sources=None
|
|
)
|
|
|
|
assert len(projected) == 10
|
|
assert len({row["_lineage_root_id"] for row in projected}) == 10
|
|
|
|
|
|
def test_project_assignment_filters_are_exact_complements(tmp_path):
|
|
"""'assigned' and 'unassigned' must partition the logical conversations."""
|
|
rows = [
|
|
_session("plain-a", BASE_TS + 10),
|
|
_session("plain-b", BASE_TS + 20),
|
|
_session("owned", BASE_TS + 30, project_id="project-a"),
|
|
]
|
|
# A lineage whose assignment only exists on the tip is assigned as a whole.
|
|
rows.extend(_lineage("chain", BASE_TS + 40, 3, tip_project_id="project-b"))
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(db_path, rows)
|
|
|
|
def _ids(**kwargs):
|
|
return {
|
|
row["id"]
|
|
for row in agent_sessions.read_importable_agent_session_rows(
|
|
db_path, limit=50, exclude_sources=None, **kwargs
|
|
)
|
|
}
|
|
|
|
everything = _ids()
|
|
assigned = _ids(project_assignment="assigned")
|
|
unassigned = _ids(project_assignment="unassigned")
|
|
|
|
assert assigned == {"owned", "chain-seg2"}
|
|
assert unassigned == {"plain-a", "plain-b"}
|
|
assert assigned | unassigned == everything
|
|
assert not assigned & unassigned
|
|
|
|
|
|
def test_project_assignment_rejects_unknown_values(tmp_path):
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(db_path, [_session("plain", BASE_TS)])
|
|
|
|
with pytest.raises(ValueError):
|
|
agent_sessions.read_importable_agent_session_rows(
|
|
db_path, limit=5, project_assignment="maybe"
|
|
)
|
|
|
|
|
|
def test_assigned_filter_is_empty_on_schemas_without_project_id(tmp_path):
|
|
db_path = tmp_path / "state.db"
|
|
_write_state_db(db_path, [_session("plain", BASE_TS)], lineage_columns=False)
|
|
with sqlite3.connect(str(db_path)) as conn:
|
|
conn.execute("ALTER TABLE sessions DROP COLUMN project_id")
|
|
|
|
assert agent_sessions.read_importable_agent_session_rows(
|
|
db_path, limit=5, exclude_sources=None, project_assignment="assigned"
|
|
) == []
|
|
unassigned = agent_sessions.read_importable_agent_session_rows(
|
|
db_path, limit=5, exclude_sources=None, project_assignment="unassigned"
|
|
)
|
|
assert [row["id"] for row in unassigned] == ["plain"]
|
|
|
|
|
|
def test_schema_without_project_id_retains_prior_behavior(fake_hermes_home, tmp_path):
|
|
"""An old state.db that cannot persist assignments is unchanged by all this.
|
|
|
|
The profile HAS a project registered, so the recovery pass is only skipped by
|
|
the schema probe. ``_state_db_supports_project_ids`` is asserted directly
|
|
because dropping the probe is behaviour-neutral (the assigned filter already
|
|
returns nothing for such a schema) — it is a query-budget guard, so the
|
|
contract worth pinning is the probe's own answer plus the unchanged output.
|
|
"""
|
|
_register_projects(tmp_path, "project-live")
|
|
|
|
db_path = fake_hermes_home / "state.db"
|
|
rows = [_session(f"plain-{index:02d}", BASE_TS + index) for index in range(25)]
|
|
_write_state_db(db_path, rows, lineage_columns=False)
|
|
assert models._state_db_supports_project_ids(db_path) is True
|
|
with sqlite3.connect(str(db_path)) as conn:
|
|
conn.execute("ALTER TABLE sessions DROP COLUMN project_id")
|
|
models.clear_cli_sessions_cache()
|
|
|
|
assert models._state_db_supports_project_ids(db_path) is False
|
|
|
|
sessions = models.get_cli_sessions()
|
|
|
|
assert len(sessions) == models.CLI_VISIBLE_SESSION_LIMIT == 20
|
|
assert all(session["project_id"] is None for session in sessions)
|
|
# Still the newest 20, in the pre-existing order.
|
|
assert [session["session_id"] for session in sessions][:3] == [
|
|
"plain-24", "plain-23", "plain-22",
|
|
]
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_unassigned_refill_cannot_clobber_a_resolved_assignment(
|
|
fake_hermes_home, tmp_path
|
|
):
|
|
"""Recovery pass 2 must never un-fix this PR's own headline bug.
|
|
|
|
Pass 2 merges its rows into the same lineage-keyed table pass 1 fills, and
|
|
it used to overwrite an existing entry unconditionally. When the SQL
|
|
``project_assignment='unassigned'`` filter and the Python lineage projection
|
|
disagree about where a compression lineage starts, pass 2 hands back the
|
|
lineage ROOT as its own "unassigned" conversation — and the unconditional
|
|
overwrite then replaced the assigned row with it, deleting the project chip
|
|
and swapping the session identity from the lineage tip back to the root.
|
|
|
|
They disagree here on a blank ``source``: ``_is_continuation_session``
|
|
treats an empty/whitespace source as "unknown, same conversation", while the
|
|
SQL lineage CTE compares ``LOWER(TRIM(parent.source)) = LOWER(TRIM(...))``
|
|
and so refuses to walk from the assigned tip up to the blank-source root.
|
|
The merge has to be safe whether or not the two ever line up exactly.
|
|
"""
|
|
_register_projects(tmp_path, "project-live")
|
|
|
|
rows = [
|
|
# Blank source: SQL will not join this root to its assigned tip, but the
|
|
# Python projection collapses the pair into one row keyed on both ids.
|
|
_session(
|
|
"chain-root",
|
|
BASE_TS + 500,
|
|
source="",
|
|
ended_at=BASE_TS + 505,
|
|
end_reason="compression",
|
|
),
|
|
_session(
|
|
"chain-tip", BASE_TS + 510, parent="chain-root", project_id="project-live"
|
|
),
|
|
]
|
|
# Two spare unassigned rows so the window is short and pass 2 actually runs.
|
|
rows.extend(_session(f"plain-{index}", BASE_TS + index) for index in range(2))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
by_id = {s["session_id"]: s for s in models.get_cli_sessions()}
|
|
|
|
# Was: "chain-tip" gone, "chain-root" present with project_id None.
|
|
assert "chain-tip" in by_id, "pass 2 replaced the assigned row with its root"
|
|
assert by_id["chain-tip"]["project_id"] == "project-live"
|
|
assert "chain-root" not in by_id, "the lineage must stay one conversation"
|
|
|
|
|
|
# ── Rebase guard: upstream's kanban pass shares the system-chip helper ────────
|
|
|
|
|
|
def test_background_source_exclusion_literal_matches_the_constant():
|
|
"""The interactive exclusion stays spelled as a tuple literal.
|
|
|
|
``tests/test_issue2841_show_cron_sessions_toggle.py`` reads that call site as
|
|
SOURCE TEXT (``'("cron", "webhook", "kanban") if source_filter is None'``),
|
|
so replacing the literal with ``BACKGROUND_CLI_SOURCES`` turned a
|
|
pre-existing test red. The literal is therefore kept, and this test pins it
|
|
to the constant the new code paths use so the two cannot drift apart.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
source = Path(models.__file__).read_text(encoding="utf-8")
|
|
literal = ", ".join(f'"{name}"' for name in models.BACKGROUND_CLI_SOURCES)
|
|
assert f"exclude_sources=({literal}) if source_filter is None else None" in source, (
|
|
"the interactive exclude_sources literal must list exactly "
|
|
f"BACKGROUND_CLI_SOURCES ({models.BACKGROUND_CLI_SOURCES!r})"
|
|
)
|
|
assert '("cron", "webhook", "kanban") if source_filter is None' in source, (
|
|
"test_issue2841_show_cron_sessions_toggle.py pins this exact substring"
|
|
)
|
|
|
|
|
|
def test_kanban_second_pass_still_projects_rows(fake_hermes_home, tmp_path):
|
|
"""The system-chip helper keeps the (sid, source) signature kanban calls.
|
|
|
|
Upstream's kanban pass calls ``_state_row_project_id(sid, _source)``; giving
|
|
that helper a row-dict signature turned the whole pass into a swallowed
|
|
TypeError and silently dropped every kanban row.
|
|
"""
|
|
_register_projects(tmp_path, "project-live")
|
|
|
|
rows = [_session("kanban-old", BASE_TS, source="kanban")]
|
|
rows.extend(_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25))
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
sessions = models.get_cli_sessions()
|
|
by_id = {session["session_id"]: session for session in sessions}
|
|
|
|
assert "kanban-old" in by_id
|
|
# Upstream semantics: kanban rows get no chip from this helper.
|
|
assert by_id["kanban-old"]["project_id"] is None
|
|
|
|
|
|
def test_background_row_chip_is_the_same_on_both_projection_paths(
|
|
fake_hermes_home, tmp_path
|
|
):
|
|
"""One kanban row must not report two different chips.
|
|
|
|
The interactive projection loop is also what a ``source_filter='kanban'``
|
|
scan walks, so resolving state.db assignments there without excusing
|
|
background sources gave the SAME row ``None`` in the default view (its own
|
|
bounded second pass) and a resolved id under ``?source=kanban``.
|
|
"""
|
|
_register_projects(tmp_path, "project-live")
|
|
|
|
rows = [_session("kan-1", BASE_TS, source="kanban", project_id="project-live")]
|
|
rows.extend(
|
|
_session(f"recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)
|
|
)
|
|
_write_state_db(fake_hermes_home / "state.db", rows)
|
|
|
|
default_view = {s["session_id"]: s for s in models.get_cli_sessions()}
|
|
kanban_view = {s["session_id"]: s for s in models.get_cli_sessions("kanban")}
|
|
|
|
assert "kan-1" in default_view
|
|
assert "kan-1" in kanban_view
|
|
assert default_view["kan-1"]["project_id"] is None
|
|
assert kanban_view["kan-1"]["project_id"] == default_view["kan-1"]["project_id"]
|
|
|
|
|
|
# ── The all-profiles sidebar view runs the same recovery passes ───────────────
|
|
|
|
|
|
def test_all_profiles_view_recovers_project_assigned_sessions(
|
|
fake_hermes_home, tmp_path, monkeypatch
|
|
):
|
|
"""``?all_profiles=1`` truncates by recency too, so it needs both passes.
|
|
|
|
That path passes ``visible_session_limit=None``, which reads like
|
|
"unbounded" but is mapped to ``CLI_VISIBLE_SESSION_LIMIT`` for the
|
|
interactive pass — only the cron/webhook/kanban limits reach the reader as
|
|
``limit=`` directly, where None really does mean unbounded. Skipping the
|
|
assigned recovery pass here therefore lost exactly the sessions this
|
|
regression is about, in the one view that shows every profile at once.
|
|
"""
|
|
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
|
_register_projects(tmp_path, "project-123")
|
|
|
|
db_path = fake_hermes_home / "state.db"
|
|
rows = [
|
|
_session(f"recent-{index:02d}", BASE_TS + 100 + index)
|
|
for index in range(25)
|
|
]
|
|
rows.append(_session("older-assigned", BASE_TS, project_id="project-123"))
|
|
_write_state_db(db_path, rows)
|
|
|
|
# Enumerating real profiles shells out to the agent CLI; pin the one context.
|
|
monkeypatch.setattr(
|
|
models,
|
|
"_all_profiles_cli_contexts",
|
|
lambda: ([(fake_hermes_home, db_path, "default")], (("home", "default", 1),)),
|
|
)
|
|
|
|
sessions = models.get_cli_sessions(all_profiles=True)
|
|
by_id = {session["session_id"]: session for session in sessions}
|
|
|
|
assert "older-assigned" in by_id
|
|
assert by_id["older-assigned"]["project_id"] == "project-123"
|
|
assert [s["session_id"] for s in sessions].count("older-assigned") == 1
|
|
# The unassigned window is still bounded — the recovery pass is additive.
|
|
assert sum(1 for s in sessions if s["project_id"] is None) == 5
|