fix(sessions): scope project-id resolution to the session's profile
The review asked for "confirming the project still exists for the session's profile". profile_scoped_project_ids() read get_active_profile_name(), but _load_cli_sessions_uncached runs once per profile context in the ?all_profiles=1 view, so every context resolved against the ACTIVE profile's catalog. A LIVE assignment owned by another profile was therefore misread as unresolvable: coerced to None, skipped by the assigned recovery pass, and unreachable by the unassigned refill too (its state.db row does carry a project_id, which the "unassigned" SQL filter excludes), so the row left the payload entirely — while its chip stayed selectable in that same view, because /api/projects returns every project when all_profiles=1. Thread the ctx_profile already available at the call site through to the catalog read. The safe direction is unchanged: an id that belongs to a different profile, or to no project at all, still coerces to None.
This commit is contained in:
1 parent
27230d4480
commit
084f70df36
2 files changed
+85
-7
No files matched your search
+27
-7
@@ -7372,8 +7372,14 @@ def _profile_has_user_projects(profile: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def profile_scoped_project_ids() -> frozenset[str]:
|
||||
"""Project IDs that currently exist for the active profile.
|
||||
# Sentinel for profile_scoped_project_ids(): "no profile given, use the active
|
||||
# one". A bare None cannot mean that, because None is a legitimate profile value
|
||||
# (_profiles_match reads it as the root profile).
|
||||
_ACTIVE_PROFILE = object()
|
||||
|
||||
|
||||
def profile_scoped_project_ids(profile=_ACTIVE_PROFILE) -> frozenset[str]:
|
||||
"""Project IDs that currently exist for ``profile`` (default: the active one).
|
||||
|
||||
A state.db ``project_id`` is an opaque string the agent wrote; the project
|
||||
it names can since have been deleted, or can belong to a different profile.
|
||||
@@ -7382,6 +7388,13 @@ def profile_scoped_project_ids() -> frozenset[str]:
|
||||
the row out of "Unassigned" and turn it into a ``default_hidden`` row with no
|
||||
project chip left to reveal it (#6659 review finding 3).
|
||||
|
||||
``profile`` must be the SESSION's profile, not the process-wide active one,
|
||||
wherever the two can differ. The all-profiles sidebar view scans one state.db
|
||||
per profile in a single request, so resolving every context against the active
|
||||
profile made another profile's LIVE assignment look unresolvable and dropped
|
||||
the row from the payload entirely — while its chip stayed selectable in that
|
||||
same view (#6659 review finding 3).
|
||||
|
||||
Profile/alias matching is delegated to ``api.profiles._profiles_match``,
|
||||
the canonical helper every other profile-scoped read already uses, so this
|
||||
set cannot disagree with the rest of the app about which profile owns a
|
||||
@@ -7390,7 +7403,7 @@ def profile_scoped_project_ids() -> frozenset[str]:
|
||||
"""
|
||||
from api.profiles import get_active_profile_name, _profiles_match
|
||||
|
||||
active = get_active_profile_name()
|
||||
active = get_active_profile_name() if profile is _ACTIVE_PROFILE else profile
|
||||
resolved: set[str] = set()
|
||||
for p in load_projects():
|
||||
project_id = str(p.get('project_id') or '').strip()
|
||||
@@ -8392,15 +8405,22 @@ def _load_cli_sessions_uncached(
|
||||
_webhook_pid_cache[0] = ensure_webhook_project(profile=_cli_profile)
|
||||
return _webhook_pid_cache[0]
|
||||
|
||||
# The live project catalog for this profile, read lazily and at most once per
|
||||
# scan (same [resolved, value] cell pattern as _cron_pid above, for the same
|
||||
# #4842 reason: a per-row load_projects() is a cold-sidebar I/O blowup).
|
||||
# The live project catalog for THIS SCAN'S profile, read lazily and at most
|
||||
# once per scan (same [resolved, value] cell pattern as _cron_pid above, for
|
||||
# the same #4842 reason: a per-row load_projects() is a cold-sidebar I/O
|
||||
# blowup).
|
||||
#
|
||||
# Scoped to ``_cli_profile`` — the profile whose state.db this scan is
|
||||
# reading — not to the process-wide active profile. The all-profiles view
|
||||
# calls this loader once per profile context in a single request, so the
|
||||
# active-profile catalog would misread another profile's LIVE assignment as
|
||||
# unresolvable and drop the row from the payload (#6659 review finding 3).
|
||||
_known_project_ids_cache: list = [False, frozenset()]
|
||||
def _known_project_ids() -> frozenset[str]:
|
||||
if not _known_project_ids_cache[0]:
|
||||
_known_project_ids_cache[0] = True
|
||||
try:
|
||||
_known_project_ids_cache[1] = profile_scoped_project_ids()
|
||||
_known_project_ids_cache[1] = profile_scoped_project_ids(_cli_profile)
|
||||
except Exception:
|
||||
logger.debug("Project catalog read failed for CLI projection", exc_info=True)
|
||||
return _known_project_ids_cache[1]
|
||||
|
||||
@@ -1695,3 +1695,61 @@ def test_all_profiles_view_recovers_project_assigned_sessions(
|
||||
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
|
||||
|
||||
|
||||
def test_all_profiles_view_resolves_each_context_against_its_own_profile(
|
||||
fake_hermes_home, tmp_path, monkeypatch
|
||||
):
|
||||
"""Review finding 3 asked for "the SESSION's profile", not the active one.
|
||||
|
||||
``_load_cli_sessions_uncached`` runs once per profile context here, but the
|
||||
project catalog it resolved against came from ``get_active_profile_name()``.
|
||||
So a LIVE assignment owned by another profile was misread as unresolvable:
|
||||
coerced to None, skipped by the assigned recovery pass, and unreachable by the
|
||||
unassigned refill too (its state.db row DOES carry a ``project_id``, which the
|
||||
"unassigned" SQL filter excludes) — the row left the payload entirely. Its
|
||||
chip is selectable in this very view (``/api/projects`` returns every project
|
||||
when ``all_profiles=1``), so selecting it showed nothing: exactly the
|
||||
"assignment exists but the session is unreachable" failure finding 3 named.
|
||||
"""
|
||||
monkeypatch.setattr(models, "CLI_VISIBLE_SESSION_LIMIT", 5)
|
||||
_register_projects(tmp_path, "project-a")
|
||||
_register_projects(tmp_path, "project-b", profile="other")
|
||||
|
||||
home_a = fake_hermes_home
|
||||
db_a = home_a / "state.db"
|
||||
rows_a = [_session(f"a-recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)]
|
||||
rows_a.append(_session("older-assigned-a", BASE_TS, project_id="project-a"))
|
||||
_write_state_db(db_a, rows_a)
|
||||
|
||||
home_b = tmp_path / "hermes-other"
|
||||
home_b.mkdir()
|
||||
db_b = home_b / "state.db"
|
||||
rows_b = [_session(f"b-recent-{index:02d}", BASE_TS + 100 + index) for index in range(25)]
|
||||
rows_b.append(_session("older-assigned-b", BASE_TS, project_id="project-b"))
|
||||
# The safe direction, in the same scan: project-a belongs to the OTHER
|
||||
# profile as far as this context is concerned, so it must still coerce to
|
||||
# None instead of gaining a chip that cannot reveal it.
|
||||
rows_b.append(_session("newest-foreign-b", BASE_TS + 900, project_id="project-a"))
|
||||
_write_state_db(db_b, rows_b)
|
||||
|
||||
# Enumerating real profiles shells out to the agent CLI; pin the two contexts.
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_all_profiles_cli_contexts",
|
||||
lambda: (
|
||||
[(home_a, db_a, "default"), (home_b, db_b, "other")],
|
||||
(("home-a", "default", 1), ("home-b", "other", 1)),
|
||||
),
|
||||
)
|
||||
|
||||
sessions = models.get_cli_sessions(all_profiles=True)
|
||||
by_id = {session["session_id"]: session for session in sessions}
|
||||
|
||||
# The active profile's own context is unaffected.
|
||||
assert by_id["older-assigned-a"]["project_id"] == "project-a"
|
||||
# The other profile's LIVE assignment survives, with its own chip.
|
||||
assert "older-assigned-b" in by_id
|
||||
assert by_id["older-assigned-b"]["project_id"] == "project-b"
|
||||
# A foreign id is still not an assignment.
|
||||
assert by_id["newest-foreign-b"]["project_id"] is None
|
||||
Reference in new issue
Block a user