Prune orphan native-WebUI zero-message sessions from sidebar (#4985)

Sidebar rows whose backing state.db session has zero messages (a '+'-click
that opened a row but the first turn never committed, or a sidebar nav that
opened then closed before any message landed) now get pruned instead of
lingering forever. The existing #3238/#4591 orphan-prune path only handled
CLI/API-server sidecars; this adds a parallel pass for native-WebUI rows.

- api/models.py: add agent_session_zero_message_sids() batch helper that
  mirrors agent_session_rows_existing()'s safe-degrade contract (returns
  frozenset() on any error so a transient failure never causes data loss).
  Add a small tombstone file at SESSION_DIR/_pruned_webui_orphans.json
  (capped at the last 500 sids) so the next poll's
  recover_missing_index_sidecars does not re-add a pruned orphan to the
  sidebar index — the per-poll fsync'd index write + state.db probe loop
  the original PR would have hit on every poll for every orphan. The
  helper, the load, and the full-scan fallback all honor the tombstone.
  new_session() and import_cli_session() clear any matching tombstone
  entry so a fresh session with the same id isn't shadowed.
- api/routes.py: extract the webui zero-message prune into a private
  _prune_orphaned_webui_zero_message_sessions() helper, then call it
  from BOTH branches of _build_session_list_cache_payload. Established
  installs pin show_cli_sessions=False (per api/config.py:7637-7648)
  and are exactly the long-time users who accumulated the #4985 404
  orphans — the original PR only ran the prune in the if branch and
  silently skipped the else branch (maintainer review
  IC_kwDOR1LuPM8AAAABHsyFGg). The helper also honors the tombstone
  defensively so the prune can't thrash on a row the index still
  carries from a prior poll.
- tests/test_issue4985_orphaned_webui_zero_message.py: 39 regression
  tests — the original 25 (7 helper-direct + 12 monkeypatch predicate
  tests + 6 real-pipeline end-to-end tests) plus 14 follow-up tests
  driven by the MUST-FIX / tombstone / r6 signal-swap reviews:
    * test_helper_prunes_orphan_keeps_safety_retained_rows — direct call
      to the extracted helper with a row set covering every shape the
      gate is responsible for.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false
      — explicit regression for the established-install case the
      original PR missed.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
      — companion positive control.
    * test_tombstone_persists_across_polls — drives two pipeline builds,
      asserts prune fires once total and the tombstoned sid is NOT
      re-added to SESSION_INDEX_FILE.
    * test_tombstone_does_not_block_new_session_with_same_id — defensive
      cover for the new_session() / import_cli_session() tombstone clear.
    * test_tombstone_trimmed_to_last_N_entries — verifies the cap.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
      — companion positive control (show_cli_sessions=True path).
    * test_tombstone_self_heals_when_message_added — confirms
      Session.save()'s belt-and-suspenders tombstone clear fires when
      a sidecar commit lands with len(messages) > 0.
    * test_tombstone_loader_is_fail_open_on_corrupt_file[non-json|version-mismatch|non-dict-root]
      — 3-parameter regression for the fail-open corrupt-file contract
      (#5023 lesson).
    * test_sidecar_only_webui_session_is_retained — sidecar JSON carries
      real messages but state.db.messages is empty (mirroring lag); the
      r6 loaded-sidecar probe retains the row.
    * test_sidecar_only_webui_session_self_heals_tombstone — the r4.5
      self-heal path combined with the r6 sidecar-only retention rule.
    * test_truly_empty_sidecar_with_title_still_pruned — companion
      negative test that the r6 fix does NOT over-retain empty sidecars
      with non-Untitled titles.
    * test_sidecar_only_webui_session_retained_when_show_cli_sessions_false
      — established-install path also respects the r6 retention rule.
- CHANGELOG.md: Unreleased entry under Fixed.

fix: target post-#1171 survivors + hoist + tombstone + r6 sidecar-load probe

The r5 sidecar-only retention signal keyed off the row's cached
``message_count`` — which is stale-positive on the very phantom rows
#4985 exists to prune (sidecar ``messages`` empty but cached count > 0).
The r6 signal swaps that cached-count probe for a ``Session.load(sid)
.messages`` probe, gated to ``state.db``-empty candidates only so the
common live-row path stays a no-op. Three retain tests now write real
populated ``messages`` arrays (not just ``[]``) so the loaded-sidecar
check has something to find; the stale-count phantom test is left as
the canonical r6 regression (its ``messages=[]`` + ``message_count=5``
sidecar is exactly the shape the r6 loaded-sidecar check correctly
classifies as orphan). Maintainer review 4584722701.
This commit is contained in:
nanw committed 2026-06-28 12:14:58 +08:00
1 parent ffef6fa4ec
commit e8f1bb10da
4 files changed
+2800 -3

No files matched your search

+277 -2
View File
@@ -2008,6 +2008,221 @@ def _session_list_cache_done(key: tuple, event: threading.Event | None) -> None:
event.set()
def _prune_orphaned_webui_zero_message_sessions(rows, *, diag_stage=None):
"""#4985 second-pass orphan prune for native-WebUI rows whose ``state.db.messages`` is empty.
Takes the post-``#3238`` ``webui_sessions`` list (i.e. rows that already
survived the #3238/#4591 CLI/API-server prune) and returns a NEW list with
any row whose backing ``state.db.messages`` table is empty removed.
Removed sids are also persisted to the tombstone via
``_record_webui_zero_message_orphan_tombstone`` so
``recover_missing_index_sidecars`` does not re-add them to the sidebar
index on the next poll (avoids the cache-thrash loop where every poll
does one fsync'd index write + one state.db probe per orphan, forever).
Invariants preserved:
- Rows with ``active_stream_id`` / ``has_pending_user_message`` /
``worktree_path`` set are NEVER pruned — the inflight / worktree-bound
/ pending safety contract from ``IC_kwDOR1LuPM8AAAABHrkF1Q``.
- Rows whose ``state.db.messages`` is empty AND that survived the
upstream ``all_sessions()`` ``#1171`` keep-filter (i.e. titled OR has
positive ``message_count``) ARE pruned — the post-#1171-survivor
shape #4985 actually describes (a row that lingers VISIBLY in the
sidebar because of a stale positive ``message_count`` or a title set
before the first turn committed).
This helper is intentionally extracted out of the ``if show_cli_sessions:``
branch so the prune fires in BOTH branches of
``_build_session_list_cache_payload``. Established installs have
``settings.show_cli_sessions`` pinned to ``False`` (per
``api/config.py:7637-7648``) and those are exactly the long-time users
who have accumulated the #4985 404 orphans — without hoisting, the
``else:`` branch silently skipped the prune and the sidebar kept
dangling rows that 404 on click (review
``IC_kwDOR1LuPM8AAAABHsyFGg``).
"""
if not rows:
return list(rows) if rows is not None else []
_diag = diag_stage if callable(diag_stage) else (lambda *_a, **_k: None)
# #4985 self-healing: the tombstone is NOT a blind-drop filter at the
# top of the helper. A row whose sid is in the tombstone is allowed
# into the gate predicate like any other row — and the post-probe
# logic below explicitly distinguishes four cases:
#
# 1. probe says NOT empty AND sid IS tombstoned → SELF-HEAL: the row
# has actually gained messages, so clear the tombstone and keep
# the row (do NOT add to missing_webui_orphan_ids). This is the
# primary fix for review IC_kwDOR1LuPM8AAAABHvY-dw.
# 2. probe says empty AND sid IS tombstoned → tombstone persists
# (orphan shape unchanged), but the row is excluded from the
# returned list so the tombstone continues to suppress it on
# this poll too. Do NOT redundantly prune+tombstone (would
# cycle).
# 3. probe says empty AND sid is NOT tombstoned → new orphan: prune
# from index, record tombstone, diag_stage.
# 4. probe says NOT empty AND sid is NOT tombstoned → row has
# messages, retain (gate already passes anyway).
#
# A blind-drop at the top (the previous behavior) is strictly worse
# than the orphan it suppresses — it would silently swallow a
# legitimately-resurfaced row forever, even after the user actually
# sent messages. The self-healing case is what makes the tombstone a
# recoverable "this sid is currently empty" signal rather than a
# permanent hide-list.
if not rows:
return []
# Gate predicate mirrors the inline block that lived here before the
# helper extract. The (title!='Untitled' OR count>0) clause is what makes
# this gate actually reach a row #1171 kept — without it, the gate is a
# no-op because ``all_sessions()`` at ``api/models.py:3892-3898`` (and
# its full-scan fallback at 3946-3952) has already stripped every
# (Untitled ∧ count==0 ∧ ¬active_stream_id ∧ ¬has_pending_user_message ∧
# ¬worktree_path) row before our prune block runs.
_webui_orphan_probe_rows = [
s for s in rows
if _session_source_is_webui(s)
and not s.get("active_stream_id")
and not s.get("has_pending_user_message")
and not s.get("worktree_path")
and (
s.get("title", "Untitled") != "Untitled"
or _numeric_count(s.get("message_count")) > 0
)
]
if not _webui_orphan_probe_rows:
return list(rows)
rows_by_profile_webui: dict[object, list[dict]] = defaultdict(list)
for row in _webui_orphan_probe_rows:
rows_by_profile_webui[row.get("profile")].append(row)
_tombstoned = _load_webui_zero_message_orphan_tombstone()
self_healed_ids: set[str] = set()
missing_webui_orphan_ids: set[str] = set()
still_hidden_ids: set[str] = set()
for profile_key, profile_rows in rows_by_profile_webui.items():
probe_ids = [
str(row.get("session_id")).strip()
for row in profile_rows
if str(row.get("session_id") or "").strip()
]
zero_message_sids = agent_session_zero_message_sids(
probe_ids,
profile=profile_key if isinstance(profile_key, str) and profile_key else None,
)
# Iterate over the actual rows (not just probe_ids) so each sid
# decision can probe the sidecar for real ``messages``. The r5
# signal keyed off the row's cached ``message_count`` (which is
# stale-positive on the very phantom rows #4985 exists to prune:
# sidecar ``messages`` empty but cached count > 0), so the r5
# retain branch kept the phantom and re-opened the bug (maintainer
# review 4584722701, supersedes the r5 cached-count signal). The
# r6 signal probes ``Session.load(sid).messages`` directly — but
# ONLY for ``state.db``-empty candidates (the small set; the
# common live-row path takes the ``else`` branch and pays
# nothing). Full ``Session.load`` is intentional (vs
# ``load_metadata_only`` which zeroes the messages array at
# ``api/models.py:1210``).
for row in profile_rows:
sid = str(row.get("session_id") or "").strip()
if not sid:
continue
is_empty = sid in zero_message_sids
is_tombstoned = sid in _tombstoned
if is_empty:
# ``state.db.messages`` is empty. Probe the sidecar JSON
# for real messages — the cached ``message_count`` alone
# is stale-positive on phantom rows (sidecar ``messages``
# empty but cached count > 0) and would retain the very
# phantom this feature exists to prune (maintainer review
# 4584722701, supersedes the r5 cached-count signal).
# Full ``Session.load`` is intentional (vs
# ``load_metadata_only`` which zeros the messages array
# at ``api/models.py:1210``); the common live-row path
# pays nothing because it skips the load via the
# ``else`` branch below.
try:
from api.models import Session as _Session
_loaded = _Session.load(sid)
sidecar_has_messages = bool(
_loaded is not None and len(_loaded.messages or []) > 0
)
except Exception:
logger.debug(
"Failed to load sidecar for webui orphan decision %s; "
"treating as empty for prune purposes",
sid,
exc_info=True,
)
sidecar_has_messages = False
else:
# ``state.db.messages`` is non-empty — the conversation is real.
sidecar_has_messages = True
if sidecar_has_messages:
# Real transcript (state.db OR loaded sidecar). Retain; if
# tombstoned, self-heal so it stops thrashing on recovery.
if is_tombstoned:
self_healed_ids.add(sid)
continue
if not is_empty and is_tombstoned:
# Case 1: SELF-HEAL — clear tombstone, keep row.
self_healed_ids.add(sid)
elif is_empty and is_tombstoned:
# Case 2: still-empty tombstoned row stays hidden this
# poll (do not add to missing_webui_orphan_ids — would
# cycle through prune_session_from_index + record).
still_hidden_ids.add(sid)
elif is_empty and not is_tombstoned:
# Case 3: new orphan.
missing_webui_orphan_ids.add(sid)
# Case 4 (not empty + not tombstoned): row has messages, retain.
if self_healed_ids:
for _sid in self_healed_ids:
try:
_clear_webui_zero_message_orphan_tombstone(_sid)
logger.debug(
"self-heal: cleared webui zero-message orphan tombstone "
"for %s (state.db.messages now non-empty)",
_sid,
)
except Exception:
logger.debug(
"Failed to clear webui zero-message orphan tombstone for %s",
_sid,
exc_info=True,
)
_diag("self_heal_webui_zero_message_orphan")
if missing_webui_orphan_ids:
for _sid in missing_webui_orphan_ids:
try:
prune_session_from_index(_sid)
_diag("prune_orphaned_webui_zero_message")
except Exception:
logger.debug(
"Failed to prune orphaned webui zero-message row %s",
_sid,
exc_info=True,
)
# Tombstone the sid in a SECOND step so a tombstone-write failure
# never blocks the prune itself (the prune still removes the row
# from the sidebar; only the re-prune avoidance would degrade).
try:
_record_webui_zero_message_orphan_tombstone(_sid)
except Exception:
logger.debug(
"Failed to tombstone webui zero-message orphan %s",
_sid,
exc_info=True,
)
# Return rows excluding both the freshly-pruned orphans AND the
# tombstoned rows that the probe confirmed are still empty (case 2).
# Self-healed rows (case 1) and live rows (case 4) stay in the result.
_hidden = missing_webui_orphan_ids | still_hidden_ids
return [
s for s in rows
if str(s.get("session_id") or "").strip() not in _hidden
]
def _build_session_list_cache_payload(
active_profile: str | None,
all_profiles: bool,
@@ -2090,7 +2305,17 @@ def _build_session_list_cache_payload(
# CLI_VISIBLE_SESSION_LIMIT (20) — an existing session can fall
# out of that window and look deleted. Native WebUI sessions
# (source == "webui") that merely have a CLI ancestor are never
# pruned.
# pruned by this path.
#
# #4985: parallel pass for native-WebUI rows that have a backing
# agent row in state.db but zero messages (a `+`-click that opened a
# row but the first turn never committed, or a sidebar nav that
# opened then closed before any message landed). The same #3238
# helper doesn't catch these because source == "webui" is excluded
# above, and the WebUI delete affordance isn't exposed for them,
# so they would otherwise linger forever. Inflight first-turn
# safety is preserved by gating on `active_stream_id` (after
# _reconcile_stale_stream_state has cleared stale stream ids).
_orphan_probe_rows = []
_kept_after_orphan_prune = []
for s in webui_sessions:
@@ -2137,7 +2362,41 @@ def _build_session_list_cache_payload(
diag_stage("prune_orphaned_agent_sidecar")
continue
_kept_after_orphan_prune.append(s)
webui_sessions = _kept_after_orphan_prune
# #4985 second pass — probe state.db.messages for native-WebUI rows
# that *survived* the upstream all_sessions() #1171 keep-filter (so
# the row is TITLED or has a POSITIVE message_count, meaning it IS
# shown in the sidebar — and the 404 click reported in #4985 happens),
# BUT whose actual state.db.messages table is empty (the ground-truth
# probe). This is the orphan shape #4985 actually describes: a row
# that lingers VISIBLY in the sidebar because of a stale positive
# message_count or a title set before the first turn committed.
#
# The (title!='Untitled' OR count>0) clause is the part that makes
# this gate actually reach a row #1171 kept. Without it, the gate is
# a no-op because all_sessions() at api/models.py:3892-3898 and
# 3946-3952 has already stripped every (Untitled ∧ count==0 ∧
# ¬active_stream_id ∧ ¬has_pending_user_message ∧ ¬worktree_path)
# row before this point — making the earlier 6-condition gate a
# no-op against the real pipeline (review IC_kwDOR1LuPM8AAAABHrkF1Q).
#
# Implementation lives in ``_prune_orphaned_webui_zero_message_sessions``
# above so the prune runs in BOTH branches of this function
# (``if show_cli_sessions:`` AND ``else:``). Established installs
# have ``settings.show_cli_sessions`` pinned to False (per
# api/config.py:7637-7648) and those are exactly the long-time
# users who accumulated the #4985 404 orphans — without hoisting,
# the ``else:`` branch silently skipped the prune
# (review IC_kwDOR1LuPM8AAAABHsyFGg).
#
# Inflight / worktree / pending safety: same as before — any row
# still carrying active_stream_id / has_pending_user_message /
# worktree_path is never pruned, even if its messages table is
# momentarily empty. _reconcile_stale_stream_state_for_session_rows
# at line 2224 has already cleared stale stream ids above this point.
webui_sessions = _prune_orphaned_webui_zero_message_sessions(
_kept_after_orphan_prune,
diag_stage=diag_stage,
)
for s in webui_sessions:
meta = cli_by_id.get(s.get("session_id"))
if not meta:
@@ -2165,6 +2424,18 @@ def _build_session_list_cache_payload(
else:
diag_stage("filter_webui_sessions")
webui_sessions = [s for s in webui_sessions if not _is_cli_session_for_settings(s)]
# #4985 second pass — see _prune_orphaned_webui_zero_message_sessions
# for the gate predicate and the post-#1171-survivor rationale. The
# prune MUST run here too: established installs have
# ``settings.show_cli_sessions`` pinned to False
# (api/config.py:7637-7648) and those are exactly the long-time
# users who accumulated the 404 orphans — review
# IC_kwDOR1LuPM8AAAABHsyFGg. Without this call the else branch
# silently skipped the prune and the sidebar kept dangling rows.
webui_sessions = _prune_orphaned_webui_zero_message_sessions(
webui_sessions,
diag_stage=diag_stage,
)
deduped_cli = []
diag_stage("sort_sessions")
merged = webui_sessions + deduped_cli
@@ -6734,6 +7005,10 @@ from api.models import (
_hide_from_default_sidebar,
prune_session_from_index,
agent_session_rows_existing,
agent_session_zero_message_sids,
_load_webui_zero_message_orphan_tombstone,
_record_webui_zero_message_orphan_tombstone,
_clear_webui_zero_message_orphan_tombstone,
ensure_cron_project,
is_cron_session,
is_safe_session_id,