fix(sessions): stop a WebUI-side move spending an unassigned slot
/api/session/move writes project_id onto the WebUI sidecar only; nothing writes state.db.sessions.project_id. The model's unassigned counter read state.db exclusively, so it believed all 23 conversations were unassigned and stopped at its 20-row window — while the route, which sees the sidecar's project_id through all_sessions(), classified the 3 moved conversations as ASSIGNED. Net result: 17 unassigned CLI rows in the payload and cli-00/01/02 never fetched at all, the reviewer's exact 3 + 20 -> 17 number. Resolve the assignment from the source of truth the route ends up using: _state_projection_sidecar_metadata (already the UI-owned title/archived reader, already stat-cached) now also returns project_id, and _interactive_row_project_id falls back to it when state.db has none. The unassigned refill query is widened by the number of sidecar-only assignments, because the SQL 'unassigned' filter cannot see them and would otherwise spend those slots re-fetching rows already represented.
This commit is contained in:
1 parent
bc80d71177
commit
27230d4480
3 files changed
+143
-8
No files matched your search
+51
-7
@@ -8222,7 +8222,14 @@ def clear_sidecar_metadata_cache() -> None:
|
||||
|
||||
|
||||
def _state_projection_sidecar_metadata(sid: str) -> dict:
|
||||
"""Return UI-owned metadata (title + archived) for a state.db-projected row.
|
||||
"""Return UI-owned metadata (title + archived + project_id) for a state row.
|
||||
|
||||
``project_id`` is UI-owned in exactly the same way as the other two:
|
||||
``/api/session/move`` writes it onto the sidecar and NOTHING writes
|
||||
``state.db.sessions.project_id``. Reading it here is what lets the sidebar
|
||||
projection agree with the assignment ``all_sessions()`` re-surfaces, instead
|
||||
of counting a moved conversation against the unassigned window (#6659 review
|
||||
finding 2).
|
||||
|
||||
Memoized by the sidecar file's (path, mtime_ns, size, ctime_ns) stat
|
||||
signature so the sidebar projection — which calls this once per row in both
|
||||
@@ -8233,11 +8240,11 @@ def _state_projection_sidecar_metadata(sid: str) -> dict:
|
||||
Returns a COPY so callers can't mutate the cached dict.
|
||||
|
||||
NOTE: this stat-gates on ``SESSION_DIR / f'{sid}.json'`` because that file is
|
||||
``Session.load_metadata_only``'s sole source for title+archived. If that ever
|
||||
stops being true (metadata moves to another store), this gate would short-
|
||||
circuit before the real source — update both together.
|
||||
``Session.load_metadata_only``'s sole source for title+archived+project_id. If
|
||||
that ever stops being true (metadata moves to another store), this gate would
|
||||
short-circuit before the real source — update both together.
|
||||
"""
|
||||
default = {"title": None, "archived": None}
|
||||
default = {"title": None, "archived": None, "project_id": None}
|
||||
if not is_safe_session_id(sid):
|
||||
return dict(default)
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
@@ -8265,6 +8272,8 @@ def _state_projection_sidecar_metadata(sid: str) -> dict:
|
||||
if title:
|
||||
metadata["title"] = title
|
||||
metadata["archived"] = bool(getattr(webui_meta, 'archived', False))
|
||||
project_id = str(getattr(webui_meta, 'project_id', None) or '').strip()
|
||||
metadata["project_id"] = project_id or None
|
||||
|
||||
with _SIDECAR_METADATA_CACHE_LOCK:
|
||||
# Re-check under lock in case a concurrent build populated it; either
|
||||
@@ -8422,6 +8431,17 @@ def _load_cli_sessions_uncached(
|
||||
return _webhook_pid()
|
||||
return None
|
||||
|
||||
# Sidecar-carried assignments, memoized per sid for this scan.
|
||||
# _state_projection_sidecar_metadata is already stat-cached, but
|
||||
# _interactive_row_project_id is called several times per row (budget
|
||||
# seeding, the unassigned count, the projection), and one os.stat per call
|
||||
# per row is the kind of cold-sidebar I/O #4842 removed.
|
||||
_sidecar_pid_cache: dict[str, str | None] = {}
|
||||
def _sidecar_row_project_id(sid: str) -> str | None:
|
||||
if sid not in _sidecar_pid_cache:
|
||||
_sidecar_pid_cache[sid] = _state_projection_sidecar_metadata(sid).get('project_id')
|
||||
return _sidecar_pid_cache[sid]
|
||||
|
||||
def _interactive_row_project_id(row: dict) -> str | None:
|
||||
"""Project chip for an interactive (CLI/TUI/ACP) state.db row.
|
||||
|
||||
@@ -8429,6 +8449,16 @@ def _load_cli_sessions_uncached(
|
||||
payload, so the code that budgets a row and the code that renders it can
|
||||
never disagree about which project it belongs to.
|
||||
|
||||
The WebUI sidecar is consulted when state.db carries no assignment,
|
||||
because ``/api/session/move`` writes ``project_id`` onto the sidecar ONLY
|
||||
— nothing writes ``state.db.sessions.project_id``. Without this the
|
||||
"``CLI_VISIBLE_SESSION_LIMIT`` unassigned conversations" guarantee counted
|
||||
a WebUI-side assignment as unassigned while the route (which sees the
|
||||
sidecar's value through ``all_sessions()``) classified the same
|
||||
conversation as ASSIGNED, so three moved sessions silently shortened
|
||||
everyone's sidebar to 17 rows (#6659 review finding 2). state.db wins when
|
||||
it has an assignment of its own: it is the agent's own record.
|
||||
|
||||
Background sources keep the system-chip answer even when a
|
||||
``source_filter`` routes them through this loop, so one kanban row cannot
|
||||
report a resolved ``project_id`` here and ``None`` from its own bounded
|
||||
@@ -8442,7 +8472,10 @@ def _load_cli_sessions_uncached(
|
||||
or is_webhook_session(sid, source)
|
||||
):
|
||||
return _state_row_project_id(sid, source)
|
||||
return _resolved_project_id(row.get('project_id'))
|
||||
resolved = _resolved_project_id(row.get('project_id'))
|
||||
if resolved is None:
|
||||
resolved = _resolved_project_id(_sidecar_row_project_id(sid))
|
||||
return resolved
|
||||
|
||||
profile_value = _cli_profile or 'default'
|
||||
# A deleted WebUI session is tombstoned (see _record_webui_deleted_session_tombstone)
|
||||
@@ -8702,6 +8735,17 @@ def _load_cli_sessions_uncached(
|
||||
unassigned_seen = sum(
|
||||
1 for row in state_rows if _interactive_row_project_id(row) is None
|
||||
)
|
||||
# A sidecar-carried assignment is invisible to the SQL 'unassigned'
|
||||
# filter (state.db still says NULL), so the refill query would spend
|
||||
# that many of its own slots re-fetching conversations this pass already
|
||||
# classified as assigned and already represents. Widen the query by that
|
||||
# count so it can actually reach the older unassigned conversations the
|
||||
# moved rows displaced (#6659 review finding 2).
|
||||
sidecar_only_assigned = sum(
|
||||
1 for row in state_rows
|
||||
if _interactive_row_project_id(row) is not None
|
||||
and _resolved_project_id(row.get('project_id')) is None
|
||||
)
|
||||
if (
|
||||
unassigned_target
|
||||
and unassigned_seen < unassigned_target
|
||||
@@ -8716,7 +8760,7 @@ def _load_cli_sessions_uncached(
|
||||
try:
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=unassigned_target,
|
||||
limit=unassigned_target + sidecar_only_assigned,
|
||||
log=logger,
|
||||
exclude_sources=interactive_excluded,
|
||||
project_assignment='unassigned',
|
||||
|
||||
Reference in new issue
Block a user