Merge pull request #3221 from nesquena/release/stage-batchA

Release stage-batchA → v0.51.181 (agent-cache eviction teardown #3218 + streaming finalize race #3201)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-30 17:31:34 -07:00
commit 82fe933b5a
6 files changed
+132 -10

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.181] — 2026-05-30 — Release FA (stage-batchA — agent-cache eviction teardown + streaming finalize race)
### Fixed
- Cached agents evicted after session-identity mismatches, unsafe runtime refresh, credential self-heal, or skipped compression migration now go through the normal session-boundary teardown path, committing pending memory and closing provider/session resources instead of silently dropping the cache entry (#3218, closes #3215).
- Assistant streaming text is no longer lost when a stream completes while you have switched to a different session tab: the SSE `done` handler now sets the stream-finalized flag immediately (before the fade window), so a `stream_end` event arriving mid-fade can no longer trigger `_restoreSettledSession()` and overwrite the live messages with a stale server snapshot (#3201, closes #3195).
## [v0.51.180] — 2026-05-30 — Release EZ (stage-batch62 — session/agent cache ownership hardening)
### Fixed
+39 -9
View File
@@ -3577,8 +3577,11 @@ def _attempt_credential_self_heal(
return None
# 2. Evict the cached agent for this session
_evicted_entry = None
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE.pop(session_id, None)
_evicted_entry = SESSION_AGENT_CACHE.pop(session_id, None)
if _evicted_entry is not None:
_close_cached_agent_entry_at_session_boundary(session_id, _evicted_entry)
# 3. Invalidate the credential pool for this provider
invalidate_credential_pool_cache(provider_id)
@@ -3680,6 +3683,12 @@ def _close_evicted_agent_at_session_boundary(session_id: str, agent) -> bool:
return True
def _close_cached_agent_entry_at_session_boundary(session_id: str, cache_entry) -> bool:
"""Commit and tear down a popped SESSION_AGENT_CACHE entry outside the cache lock."""
agent = cache_entry[0] if isinstance(cache_entry, tuple) else None
return _close_evicted_agent_at_session_boundary(session_id, agent)
def _refresh_cached_agent_runtime(agent, agent_kwargs: dict) -> bool:
"""Refresh volatile runtime credentials on a reused cached AIAgent.
@@ -4887,6 +4896,7 @@ def _run_agent_streaming(
_agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16]
agent = None
_identity_mismatch_entry = None
with SESSION_AGENT_CACHE_LOCK:
_cached = SESSION_AGENT_CACHE.get(session_id)
if _cached and _cached[1] == _agent_sig:
@@ -4896,7 +4906,7 @@ def _run_agent_streaming(
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
logger.debug('[webui] Reusing cached agent for session %s', session_id)
else:
SESSION_AGENT_CACHE.pop(session_id, None)
_identity_mismatch_entry = SESSION_AGENT_CACHE.pop(session_id, None)
logger.warning(
'[webui] Evicted cached agent with mismatched session identity: cache_key=%s agent_session_id=%s',
session_id,
@@ -4911,6 +4921,12 @@ def _run_agent_streaming(
except Exception:
logger.debug("Lifecycle register_agent failed for cached session %s", session_id, exc_info=True)
if _identity_mismatch_entry is not None:
try:
_close_cached_agent_entry_at_session_boundary(session_id, _identity_mismatch_entry)
except Exception:
logger.debug("Failed to close identity-mismatched cached agent for session %s", session_id, exc_info=True)
if agent is not None:
# Refresh volatile runtime credentials selected from provider
# pools without discarding cross-turn agent/provider state.
@@ -4919,13 +4935,14 @@ def _run_agent_streaming(
'[webui] Cached agent runtime could not be safely refreshed; rebuilding agent for session %s',
session_id,
)
try:
if getattr(agent, '_session_db', None) is not None:
agent._session_db.close()
except Exception:
pass
_stale_runtime_entry = None
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE.pop(session_id, None)
_stale_runtime_entry = SESSION_AGENT_CACHE.pop(session_id, None)
if _stale_runtime_entry is not None:
try:
_close_cached_agent_entry_at_session_boundary(session_id, _stale_runtime_entry)
except Exception:
logger.debug("Failed to close stale-runtime cached agent for session %s", session_id, exc_info=True)
agent = None
if agent is not None:
@@ -5564,6 +5581,7 @@ def _run_agent_streaming(
# Migrate cached agent to the new session ID so the turn
# count survives context compression.
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
_skipped_agent_migration_entry = None
with SESSION_AGENT_CACHE_LOCK:
_cached_entry = SESSION_AGENT_CACHE.pop(old_sid, None)
if _cached_entry:
@@ -5571,12 +5589,18 @@ def _run_agent_streaming(
if _cached_agent_matches_session(_cached_agent, new_sid):
SESSION_AGENT_CACHE[new_sid] = _cached_entry
else:
_skipped_agent_migration_entry = _cached_entry
logger.warning(
'[webui] Skipped cached agent migration with mismatched session identity: old_sid=%s new_sid=%s agent_session_id=%s',
old_sid,
new_sid,
_cached_agent_session_identity(_cached_agent),
)
if _skipped_agent_migration_entry is not None:
try:
_close_cached_agent_entry_at_session_boundary(old_sid, _skipped_agent_migration_entry)
except Exception:
logger.debug("Failed to close skipped compression-migration cached agent for session %s", old_sid, exc_info=True)
_compressed = True
# Also detect compression via the result dict or compressor state
if not _compressed:
@@ -6450,18 +6474,24 @@ def _handle_chat_steer(handler, body: dict) -> bool:
if not text:
return bad(handler, "text required")
evicted_cached_entry = None
with _cfg.SESSION_AGENT_CACHE_LOCK:
cached = _cfg.SESSION_AGENT_CACHE.get(sid)
if cached:
agent = cached[0]
if not _cached_agent_matches_session(agent, sid):
_cfg.SESSION_AGENT_CACHE.pop(sid, None)
evicted_cached_entry = _cfg.SESSION_AGENT_CACHE.pop(sid, None)
logger.warning(
'[webui] Evicted cached agent before steer due to mismatched session identity: cache_key=%s agent_session_id=%s',
sid,
_cached_agent_session_identity(agent),
)
cached = None
if evicted_cached_entry is not None:
try:
_close_cached_agent_entry_at_session_boundary(sid, evicted_cached_entry)
except Exception:
logger.debug("Failed to close steer identity-mismatched cached agent for session %s", sid, exc_info=True)
if not cached:
# No active agent for this session — caller falls back to interrupt
return j(handler, {"accepted": False, "fallback": "no_cached_agent",
+5
View File
@@ -1821,6 +1821,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('done',e=>{
if(_streamFinalized) return;
// Set _streamFinalized IMMEDIATELY — before any fade delay. Without this,
// a stream_end event arriving during the fade window sees
// _streamFinalized=false, calls _restoreSettledSession(), and overwrites
// S.messages with stale server data (issue #3195).
_streamFinalized=true;
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
const _doneData=JSON.parse(e.data);
+9 -1
View File
@@ -87,7 +87,8 @@ def test_cached_agent_session_identity_matches_requested_sid():
assert _cached_agent_matches_session(legacy, "requested") is True
def test_handle_chat_steer_evicts_mismatched_cached_agent():
def test_handle_chat_steer_evicts_mismatched_cached_agent(monkeypatch):
import api.streaming as streaming
from api.streaming import _handle_chat_steer
class Handler:
@@ -108,6 +109,12 @@ def test_handle_chat_steer_evicts_mismatched_cached_agent():
pass
wrong_agent = SimpleNamespace(session_id="other-session", steer=lambda _text: True)
closed_entries = []
monkeypatch.setattr(
streaming,
"_close_cached_agent_entry_at_session_boundary",
lambda session_id, entry: closed_entries.append((session_id, entry)),
)
config.SESSION_AGENT_CACHE.clear()
config.SESSION_AGENT_CACHE["requested"] = (wrong_agent, "sig")
handler = Handler()
@@ -118,5 +125,6 @@ def test_handle_chat_steer_evicts_mismatched_cached_agent():
assert handler.status == 200
assert payload == {"accepted": False, "fallback": "no_cached_agent", "stream_id": None}
assert "requested" not in config.SESSION_AGENT_CACHE
assert closed_entries == [("requested", (wrong_agent, "sig"))]
config.SESSION_AGENT_CACHE.clear()
@@ -51,6 +51,22 @@ def test_evicted_agent_lifecycle_shutdown_uses_empty_messages_when_missing(monke
agent._session_db.close.assert_called_once()
def test_cached_agent_entry_lifecycle_extracts_agent_from_cache_tuple(monkeypatch):
import api.streaming as streaming
closed = []
monkeypatch.setattr(
streaming,
"_close_evicted_agent_at_session_boundary",
lambda session_id, agent: closed.append((session_id, agent)) or True,
)
agent = MagicMock()
assert streaming._close_cached_agent_entry_at_session_boundary("old-session", (agent, "sig")) is True
assert closed == [("old-session", agent)]
def test_evicted_agent_lifecycle_keeps_provider_alive_when_commit_still_dirty(monkeypatch):
import api.streaming as streaming
@@ -71,3 +87,37 @@ def test_evicted_agent_lifecycle_keeps_provider_alive_when_commit_still_dirty(mo
agent.shutdown_memory_provider.assert_not_called()
agent._session_db.close.assert_not_called()
def test_identity_mismatch_cache_evictions_close_entries_outside_cache_lock():
src = open("api/streaming.py", encoding="utf-8").read()
expected_markers = [
"_identity_mismatch_entry = SESSION_AGENT_CACHE.pop(session_id, None)",
"_stale_runtime_entry = SESSION_AGENT_CACHE.pop(session_id, None)",
"_skipped_agent_migration_entry = _cached_entry",
"evicted_cached_entry = _cfg.SESSION_AGENT_CACHE.pop(sid, None)",
"_evicted_entry = SESSION_AGENT_CACHE.pop(session_id, None)",
]
for marker in expected_markers:
assert marker in src
close_markers = [
"_close_cached_agent_entry_at_session_boundary(session_id, _identity_mismatch_entry)",
"_close_cached_agent_entry_at_session_boundary(session_id, _stale_runtime_entry)",
"_close_cached_agent_entry_at_session_boundary(old_sid, _skipped_agent_migration_entry)",
"_close_cached_agent_entry_at_session_boundary(sid, evicted_cached_entry)",
"_close_cached_agent_entry_at_session_boundary(session_id, _evicted_entry)",
]
lines = src.splitlines()
for marker in close_markers:
close_idx = next(i for i, line in enumerate(lines) if marker in line)
lock_idx = max(i for i, line in enumerate(lines[:close_idx]) if "with SESSION_AGENT_CACHE_LOCK:" in line)
lock_indent = len(lines[lock_idx]) - len(lines[lock_idx].lstrip())
between = lines[lock_idx + 1:close_idx]
assert any(
line.strip()
and not line.lstrip().startswith("#")
and len(line) - len(line.lstrip()) <= lock_indent
for line in between
), f"{marker} still appears inside the SESSION_AGENT_CACHE_LOCK block"
+23
View File
@@ -71,6 +71,29 @@ class TestStreamFinalized:
"'done' handler must call finalizeThinkingCard() to close thinking card"
)
def test_done_sets_stream_finalized_before_fade_window(self):
"""#3195 regression: the 'done' handler must set _streamFinalized=true
IMMEDIATELY after the early-return guard — before the fade machinery /
_finishDone() closure runs. Otherwise a stream_end event arriving during
the fade window sees _streamFinalized=false, calls _restoreSettledSession(),
and overwrites S.messages with stale server data (assistant text between
tool-call blocks vanishes on switching back to a settled session).
"""
src = read('static/messages.js')
m = re.search(r"source\.addEventListener\('done'.*?\}\);", src, re.DOTALL)
assert m, "'done' handler not found"
fn = m.group(0)
guard_idx = fn.find('if(_streamFinalized) return;')
assert guard_idx != -1, "'done' handler must early-return on _streamFinalized"
finalize_idx = fn.find('_streamFinalized=true', guard_idx)
terminal_idx = fn.find('_terminalStateReached=true', guard_idx)
assert finalize_idx != -1, "'done' handler must set _streamFinalized=true"
assert terminal_idx != -1, "'done' handler must set _terminalStateReached"
assert finalize_idx < terminal_idx, (
"_streamFinalized=true must be set immediately after the guard "
"(before _terminalStateReached / fade machinery) — #3195"
)
def test_apperror_sets_stream_finalized(self):
src = read('static/messages.js')
m = re.search(r"source\.addEventListener\('apperror'.*?\}\);", src, re.DOTALL)