Release: content search no longer evicts the working-set cache (#6084, @ai-ag2026) (#6343)

* fix(webui): keep the content search from evicting the user's working set

/api/sessions/search?content=1 walks EVERY session and pulls each one through
get_session(), which inserts it into the SESSIONS LRU and marks it
recently-used. On any install with more sessions than sessions_cache_max
(default 300), a single search therefore flushes the whole cache and refills it
with sessions the user is not looking at — the classic buffer-pool
scan-pollution problem. The sessions actually open in the UI are exactly the
ones evicted, and the search is keystroke-debounced, so it repeats while typing.

A scan reads each session exactly once, so nothing it touches has earned
"recently used". get_session_for_scan() reuses a resident session without
promoting it, and reads a cold one straight from disk without caching it. It
returns None rather than raising, since a scan skips what it cannot open.

This is a correctness fix for cache behaviour, not a latency fix. The
multi-second searches that led here were contention, not scan cost: a trivial
/api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real
sessions measures ~4s total.

test_sessions_search_depth_validation patched api.routes.get_session. With the
search reading through the scan accessor it now patches get_session_for_scan —
left unfixed, two of its cases fail and the third passes vacuously against an
empty result set.

Validation:
  pytest tests/test_issue4765_sessions_lru_eviction.py
         tests/test_sessions_search_depth_validation.py   ->  13 passed
  Both added eviction tests fail on the pre-fix accessor (verified by revert):
  the working set drops from 4/4 to 0/4 resident after one scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): stamp #6084 content-search working-set preservation

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-07-20 01:21:18 +00:00
1 parent 307b7fce5f
commit 4d9965b37a
5 files changed
+176 -26

No files matched your search

+2
View File
@@ -15,6 +15,8 @@
### Fixed ### Fixed
- **Content search no longer evicts your open sessions from the cache.** A content search scans many sessions, and each scan used to promote/cold-load sessions into the in-memory LRU — evicting the sessions you actually have open. Search now reads through a scan-only accessor that keeps the same disk-freshness, journal-recovery, and state.db reconciliation (so recent content stays searchable) but doesn't disturb the working-set cache. Thanks @ai-ag2026. (#6084, #6083)
- **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836) - **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836)
- **A turn that ends in an error no longer shows impossible "still running" tool work or a wrong duration.** On a terminal error, the settled (and reloaded) Worklog now freezes the turn's duration before cleanup and seals any still-projected tool rows as done, so a settled/reloaded transcript can't disagree with the terminal state. Successful and cancel paths are unchanged. Thanks @webtecnica. (#6323, #6309) - **A turn that ends in an error no longer shows impossible "still running" tool work or a wrong duration.** On a terminal error, the settled (and reloaded) Worklog now freezes the turn's duration before cleanup and seals any still-projected tool rows as done, so a settled/reloaded transcript can't disagree with the terminal state. Successful and cancel paths are unchanged. Thanks @webtecnica. (#6323, #6309)
+44 -24
View File
@@ -4404,17 +4404,33 @@ def _evict_sessions_over_cap(cap: int | None = None) -> int:
return evicted return evicted
def get_session(sid, metadata_only=False): def get_session_for_scan(sid):
"""Load a session, optionally with metadata only (skipping the messages array). """Read a session for a one-pass scan without disturbing the LRU.
Metadata-only loads intentionally do not populate the full-session cache. The scan uses the full canonical resolver so stale sidecars, pending journal
Otherwise a later full load could return a compact object with an empty recovery, and newer state.db transcript rows remain searchable. It only
messages list. Use this when you only need compact() metadata and not the differs from ``get_session`` in cache policy: no hit promotion, no cold-load
actual message history (e.g., for fast sidebar switching). insertion, and therefore no scan-triggered eviction.
"""
try:
return _resolve_session(sid, promote_cache=False, cache_on_miss=False)
except Exception:
logger.debug("scan load failed for session %s", sid, exc_info=True)
return None
def _resolve_session(sid, metadata_only=False, *, promote_cache=True, cache_on_miss=True):
"""Resolve a session through the canonical freshness/recovery path.
``get_session_for_scan`` shares this resolver with normal reads so that
content search sees the same disk freshness, journal-retry, and state.db
recovery behavior. Its policy merely suppresses LRU promotion and cold-load
caching; reconciliation still updates an already-resident entry when normal
resolution replaces stale contents.
""" """
with LOCK: with LOCK:
cached = SESSIONS.get(sid) cached = SESSIONS.get(sid)
if cached is not None: if cached is not None and promote_cache:
SESSIONS.move_to_end(sid) # LRU: mark as recently used SESSIONS.move_to_end(sid) # LRU: mark as recently used
if cached is not None: if cached is not None:
# Defensive cache ownership check: compression/continuation and recovery # Defensive cache ownership check: compression/continuation and recovery
@@ -4438,12 +4454,12 @@ def get_session(sid, metadata_only=False):
disk_session = Session.load(sid) disk_session = Session.load(sid)
with LOCK: with LOCK:
SESSIONS[sid] = disk_session SESSIONS[sid] = disk_session
SESSIONS.move_to_end(sid) if promote_cache:
SESSIONS.move_to_end(sid)
cached = disk_session cached = disk_session
except Exception: except Exception:
logger.debug( logger.debug(
"cached session disk-freshness check failed for session %s", "cached session disk-freshness check failed for session %s", sid, exc_info=True,
sid, exc_info=True,
) )
if not metadata_only and _inactive_cache_tail_needs_disk_check(cached): if not metadata_only and _inactive_cache_tail_needs_disk_check(cached):
try: try:
@@ -4451,28 +4467,26 @@ def get_session(sid, metadata_only=False):
if _cache_has_stale_unsaved_user_tail(cached, disk_session): if _cache_has_stale_unsaved_user_tail(cached, disk_session):
with LOCK: with LOCK:
SESSIONS[sid] = disk_session SESSIONS[sid] = disk_session
SESSIONS.move_to_end(sid) if promote_cache:
SESSIONS.move_to_end(sid)
cached = disk_session cached = disk_session
except Exception: except Exception:
logger.debug( logger.debug(
"stale cached user-tail check failed for session %s", "stale cached user-tail check failed for session %s", sid, exc_info=True,
sid, exc_info=True,
) )
if not metadata_only and _session_has_pending_journal_retry(cached): if not metadata_only and _session_has_pending_journal_retry(cached):
try: try:
_try_retry_journal_recovery_in_place(cached) _try_retry_journal_recovery_in_place(cached)
except Exception: except Exception:
logger.debug( logger.debug(
"lazy journal-retry failed on cache hit for session %s", "lazy journal-retry failed on cache hit for session %s", sid, exc_info=True,
sid, exc_info=True,
) )
if not metadata_only: if not metadata_only:
try: try:
_sync_sidecar_from_state_db_if_newer(cached) _sync_sidecar_from_state_db_if_newer(cached)
except Exception: except Exception:
logger.debug( logger.debug(
"state.db newer-sidecar sync failed on cache hit for session %s", "state.db newer-sidecar sync failed on cache hit for session %s", sid, exc_info=True,
sid, exc_info=True,
) )
return cached return cached
if metadata_only: if metadata_only:
@@ -4482,10 +4496,12 @@ def get_session(sid, metadata_only=False):
else: else:
s = Session.load(sid) s = Session.load(sid)
if s: if s:
with LOCK: if cache_on_miss:
SESSIONS[sid] = s with LOCK:
SESSIONS.move_to_end(sid) SESSIONS[sid] = s
_evict_sessions_over_cap() # #4765: safe LRU eviction (never active/unsaved) if promote_cache:
SESSIONS.move_to_end(sid)
_evict_sessions_over_cap() # #4765: safe LRU eviction (never active/unsaved)
if not metadata_only: if not metadata_only:
try: try:
synced_from_state = _sync_sidecar_from_state_db_if_newer(s) synced_from_state = _sync_sidecar_from_state_db_if_newer(s)
@@ -4499,14 +4515,13 @@ def get_session(sid, metadata_only=False):
_try_retry_journal_recovery_in_place(s) _try_retry_journal_recovery_in_place(s)
except Exception: except Exception:
logger.debug( logger.debug(
"lazy journal-retry failed on cold load for session %s", "lazy journal-retry failed on cold load for session %s", sid, exc_info=True,
sid, exc_info=True,
) )
# If repair had to bail because the per-session lock was held, # If repair had to bail because the per-session lock was held,
# do not pin the still-stale sidecar in the LRU cache forever. # do not pin the still-stale sidecar in the LRU cache forever.
# Leaving it cached would prevent future get_session() calls from # Leaving it cached would prevent future get_session() calls from
# re-entering the cache-miss repair path after the lock holder exits. # re-entering the cache-miss repair path after the lock holder exits.
if not repaired and (len(s.messages) == 0 if cache_on_miss and not repaired and (len(s.messages) == 0
and s.pending_user_message and s.pending_user_message
and s.active_stream_id and s.active_stream_id
and s.active_stream_id not in _active_stream_ids()): and s.active_stream_id not in _active_stream_ids()):
@@ -4519,6 +4534,11 @@ def get_session(sid, metadata_only=False):
raise KeyError(sid) raise KeyError(sid)
def get_session(sid, metadata_only=False):
"""Load a session, optionally with metadata only (skipping messages)."""
return _resolve_session(sid, metadata_only=metadata_only)
_COMPRESSION_RECOVERY_PROFILE_UNSET = object() _COMPRESSION_RECOVERY_PROFILE_UNSET = object()
+7 -1
View File
@@ -9307,6 +9307,7 @@ def _keep_latest_messaging_session_per_source(
from api.models import ( from api.models import (
Session, Session,
get_session, get_session,
get_session_for_scan,
find_compression_recovery_session, find_compression_recovery_session,
get_session_for_file_ops, get_session_for_file_ops,
new_session, new_session,
@@ -16777,7 +16778,12 @@ def _handle_sessions_search(handler, parsed):
continue continue
if content_search: if content_search:
try: try:
sess = get_session(s["session_id"]) # Scan accessor, not get_session(): a content search walks every
# session, and routing that through the LRU would evict the
# user's working set on every keystroke-debounced search.
sess = get_session_for_scan(s["session_id"])
if sess is None:
continue
msgs = sess.messages[:depth] if depth else sess.messages msgs = sess.messages[:depth] if depth else sess.messages
for m in msgs: for m in msgs:
c = _session_search_message_text(m) c = _session_search_message_text(m)
@@ -413,3 +413,122 @@ def test_stale_unsaved_shell_with_draft_stays_resident(isolated_session_env):
assert _session_is_evictable(shell) is False, ( assert _session_is_evictable(shell) is False, (
"a stale shell with an active composer draft must stay resident" "a stale shell with an active composer draft must stay resident"
) )
def test_content_search_scan_does_not_evict_the_working_set(isolated_session_env):
"""A content search must not push the user's open sessions out of the cache.
/api/sessions/search?content=1 walks EVERY session. Routing that through
get_session() inserted each one into the LRU and marked it recently-used, so
a single search over an install with more sessions than the cap flushed the
whole cache — the classic buffer-pool scan-pollution problem. The sessions
the user actually had open were the ones evicted.
get_session_for_scan() reads without promoting or inserting, so a scan is
transparent to the LRU.
"""
from api import config as _cfg
from api.config import SESSIONS
from api.models import get_session, get_session_for_scan
_cfg.SESSIONS_MAX = 5
working = []
for i in range(4):
s = _make_persisted_session(900 + i)
get_session(s.session_id) # the user opens it -> legitimately cached
working.append(s.session_id)
corpus = [_make_persisted_session(i).session_id for i in range(60)]
for sid in corpus: # what the content search does
assert get_session_for_scan(sid) is not None
for sid in working:
assert sid in SESSIONS, "a scan evicted the user's working set"
assert not any(sid in SESSIONS for sid in corpus), "the scan polluted the LRU"
assert len(SESSIONS) <= _cfg.SESSIONS_MAX
def test_scan_accessor_reuses_resident_sessions_without_promoting(isolated_session_env):
"""A scan hit must reuse the cached object but must not refresh its recency."""
from api import config as _cfg
from api.config import SESSIONS
from api.models import get_session, get_session_for_scan
_cfg.SESSIONS_MAX = 50
first = _make_persisted_session(801)
second = _make_persisted_session(802)
get_session(first.session_id)
get_session(second.session_id) # second is now the most-recent entry
order_before = list(SESSIONS.keys())
scanned = get_session_for_scan(first.session_id)
assert scanned is SESSIONS[first.session_id], "scan should reuse the resident object"
assert list(SESSIONS.keys()) == order_before, "scan must not promote in the LRU"
def test_content_search_scan_recovers_newer_state_db_without_lru_churn(
isolated_session_env, monkeypatch,
):
"""The real search path must find state.db-only recovery text without LRU churn."""
from types import SimpleNamespace
from urllib.parse import urlparse
from api import models, routes
from api.config import SESSIONS
working = _make_persisted_session(950)
stale = _make_persisted_session(
951,
messages=[{"role": "user", "content": "old prompt", "timestamp": 100.0}],
)
stale.active_stream_id = "dead-stream"
stale.pending_user_message = "recover me"
stale.pending_started_at = 102.0
stale.save()
_insert(working)
_insert(stale)
order_before = list(SESSIONS.keys())
size_before = len(SESSIONS)
recovered = [
{"role": "user", "content": "old prompt", "timestamp": 100.0},
{"role": "user", "content": "recover me", "timestamp": 102.0},
{"role": "assistant", "content": "state-db-only needle", "timestamp": 103.0},
]
monkeypatch.setattr(
models,
"get_state_db_session_summary",
lambda sid, profile=None: {"message_count": len(recovered), "last_message_at": 103.0},
)
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid, **kwargs: list(recovered),
)
monkeypatch.setattr(
routes,
"all_sessions",
lambda: [{"session_id": stale.session_id, "title": stale.title, "profile": "default"}],
)
monkeypatch.setattr(routes, "load_settings", lambda: {"api_redact_enabled": False})
monkeypatch.setattr("api.profiles.get_active_profile_name", lambda: "default")
captured = {}
monkeypatch.setattr(
routes,
"j",
lambda handler, payload, status=200, extra_headers=None: captured.update(
payload=payload, status=status,
),
)
routes._handle_sessions_search(
SimpleNamespace(),
urlparse("/api/sessions/search?q=needle&content=1&depth=0"),
)
assert captured["status"] == 200
assert captured["payload"]["count"] == 1
assert captured["payload"]["sessions"][0]["session_id"] == stale.session_id
assert list(SESSIONS.keys()) == order_before, "scan recovery must not promote the LRU"
assert len(SESSIONS) == size_before, "scan recovery must not insert or evict cache entries"
@@ -40,8 +40,11 @@ def _run_search(query):
captured["status"] = status captured["status"] = status
captured["payload"] = payload captured["payload"] = payload
# The content search reads through get_session_for_scan (the LRU-transparent
# scan accessor), not get_session — patching the wrong one here would make
# the depth assertions pass vacuously on an empty result set.
with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch( with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch(
"api.routes.get_session", return_value=session "api.routes.get_session_for_scan", return_value=session
), patch("api.profiles.get_active_profile_name", return_value="default"), patch( ), patch("api.profiles.get_active_profile_name", return_value="default"), patch(
"api.routes.j", side_effect=fake_j "api.routes.j", side_effect=fake_j
): ):