diff --git a/CHANGELOG.md b/CHANGELOG.md index f48ba439..8f6a136e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ ### Fixed +- **New conversations can no longer become unstartable seconds after you create them.** On installs with a saved browser/password-manager credential and more than a few hundred sessions, the manager would autofill the account name into the sidebar conversation filter, silently firing a content search that walked every session through the cache and evicted the brand-new, not-yet-persisted session (its only copy lived in the in-memory cache) — so `/api/session/draft` and `/api/chat/start` then 404'd and the chat could never be started. Two fixes: the search/filter inputs are now `type="search"` with per-manager opt-out attributes and the Settings + onboarding password fields get a real `
` owner (`display:contents`, layout unchanged) so managers stop treating the sidebar filter as a username field; and `_session_is_evictable()` now proves persistence before evicting even a zero-message session, so an unsaved session you're still composing stays resident. Thanks @ai-ag2026. (#6083, #4765) + - **Live reasoning no longer drops or double-renders when the turn anchor defers a paint.** On the streaming reasoning render, when the assistant-turn anchor accepted a reasoning event but deferred actually painting it, the reasoning text could go missing — and earlier attempts to fix that could briefly show the same reasoning under two owners or, with multiple reasoning segments, update the wrong row. The fallback now matches the exact anchor row identity (`run_id:seq`), so the right segment updates in place, other rows are untouched, and no duplicate owner appears — across Transparent, Compact, and hide-all modes. Thanks @franksong2702. (#6048, #5720) - **Deleting a session now cleans up its delegated subagent sessions too (no orphaned children).** When you delete a parent session, the delegated/subagent sessions it spawned (and their on-disk transcript artifacts) are now cascaded and removed, including Agent-migrated legacy delegates — while unrelated sessions, sibling branches, and any still-live child are never touched. The cascade fails closed on any uncertainty (can't confirm liveness, missing/unqueryable state.db, or no cross-process lock primitive → it skips destructive cleanup rather than risk over-deleting), cross-process cleanup is serialized with a file lock so two WebUI workers can't race, and a partial cleanup is reported honestly instead of shown as full success. Thanks @pxxD1998. (#5926, #5307) diff --git a/api/models.py b/api/models.py index 75c22b8e..e30e52b4 100644 --- a/api/models.py +++ b/api/models.py @@ -4043,6 +4043,35 @@ def _persisted_session_meta_prefix(sid) -> dict | None: return None +def _session_sidecar_exists(sid) -> bool | None: + """Return whether *sid*'s sidecar file exists on disk. + + True = the sidecar is confirmed present. + False = the sidecar is confirmed absent (a truly never-persisted session). + None = existence is indeterminate (unsafe id, or the stat raised). + + ``_session_is_evictable`` uses this to distinguish a genuinely + never-persisted empty shell (safe to grace-evict once abandoned) from a + session whose count merely could not be read this pass (stay resident). + """ + if not is_safe_session_id(sid): + return None + try: + return (SESSION_DIR / f'{sid}.json').exists() + except OSError: + return None + + +# Grace window (seconds) during which a never-persisted, empty, draftless session +# shell is protected from LRU eviction. new_session() defers the first disk write +# only in the SESSIONS cache; evicting it there permanently 404s the chat (#6083). +# After this window a still-empty, still-draftless, never-saved shell is treated as +# abandoned and becomes evictable again, so these shells can't grow unbounded past +# the cache cap. Generous (30 min) so a user composing slowly is never dropped; +# a real draft persists to disk on the first keystroke and is reloadable anyway. +_UNSAVED_SHELL_GRACE_S = 1800 + + def _session_is_evictable(s) -> bool: """Return True only when *s* can be safely dropped from the LRU (#4765). @@ -4056,10 +4085,30 @@ def _session_is_evictable(s) -> bool: on-disk ``message_count`` being at least the in-memory message count. A metadata-only stub is inherently backed by disk, so it is evictable. - Anything we cannot positively prove is safe stays resident. Using slightly - more RAM for a session we are unsure about is strictly better than evicting - an active or unsaved session (task safety invariant: a half-done memory fix - that loses a session is worse than none). + The persistence requirement holds even for a session with ZERO messages, + but only for a bounded grace window. ``new_session()`` deliberately does not + touch disk until the first message (#1171), so between "New Conversation" and + the first send this cache is the session's ONLY copy. Evicting it there + discards an un-recreatable shell: ``get_session()`` has no recreate path and + raises ``KeyError``, so the very next ``/api/session/draft`` or + ``/api/chat/start`` 404s and the session can never be started (#6083). We + therefore protect a never-persisted empty shell while it is fresh (the user + just opened it and is composing) OR while it has an active composer draft. + + A never-persisted empty shell that is BOTH stale (older than + ``_UNSAVED_SHELL_GRACE_S``) AND draftless is treated as an abandoned + "New Conversation" tab the user opened and walked away from — it becomes + evictable again so ``sessions_cache_max`` still bounds these shells and they + cannot accumulate without limit (a slow leak / OOM on installs that open many + empty chats). Anything the user is actually composing persists a draft via + ``s.save()`` on the first keystroke, so it is disk-backed and reloadable well + before the grace window expires; the window only covers the empty-and-untouched + gap right after "New Conversation". + + Anything else we cannot positively prove is safe stays resident. Using + slightly more RAM for a session we are unsure about is strictly better than + evicting an active or unsaved session (task safety invariant: a half-done + memory fix that loses a session is worse than none). """ if s is None: return True # nothing to protect; let the caller drop it @@ -4077,14 +4126,37 @@ def _session_is_evictable(s) -> bool: if getattr(s, '_loaded_metadata_only', False): return True in_memory_count = len(getattr(s, 'messages', None) or []) - if in_memory_count == 0: - # A zero-message session has nothing to lose. If it was never persisted - # (brand new, no sidecar) dropping it only discards an empty shell; the - # next access recreates it. If it is persisted, it is trivially clean. - return True disk_count = _persisted_message_count(sid) if disk_count is None: - return False # cannot prove it is on disk → keep it resident + # disk_count is None for TWO distinct reasons: the sidecar is confirmed + # absent (truly never persisted → this cache is the only copy), OR the + # sidecar exists but its count could not be read this pass (transient I/O, + # mid-write). Only the CONFIRMED-ABSENT case is eligible for grace-based + # shell eviction; an indeterminate existing-sidecar session stays resident + # (conservative — never grace-evict something we cannot prove is gone). + if in_memory_count > 0: + return False # holds unsaved messages → never drop + composer_draft = getattr(s, 'composer_draft', None) + if composer_draft: + return False # user is composing (draft present) → keep resident + if _session_sidecar_exists(sid) is not False: + # Sidecar present or existence indeterminate → not a never-persisted + # shell; do not enter the abandoned-shell grace path. + return False + # Confirmed never-persisted empty draftless shell. Protect it while fresh + # (the compose window right after "New Conversation"); once stale it is an + # abandoned tab and becomes evictable so these shells cannot accumulate + # unbounded past the cache cap (#6083 follow-up). + created_at = getattr(s, 'created_at', None) + if isinstance(created_at, (int, float)): + if (time.time() - created_at) <= _UNSAVED_SHELL_GRACE_S: + return False # fresh empty shell → protect the compose window + return True # stale, empty, draftless, never-saved → abandoned, evictable + # No usable created_at timestamp → be conservative, keep it resident. + return False + if in_memory_count == 0: + # Persisted and empty → trivially clean, nothing to lose. + return True return disk_count >= in_memory_count diff --git a/static/index.html b/static/index.html index 0ca1bc78..8f9c625f 100644 --- a/static/index.html +++ b/static/index.html @@ -200,7 +200,7 @@ - +
@@ -225,7 +225,7 @@
- + @@ -253,7 +253,7 @@
- +
Loading...
@@ -353,8 +353,9 @@ - +
@@ -1541,11 +1542,20 @@
Enter a new password to set or change it. Leave blank to keep current setting.
- + + + +