From 1575f3af67dafb9dd119eb89f9c828345a3260ec Mon Sep 17 00:00:00 2001 From: Time4Mind Date: Sun, 28 Jun 2026 11:17:09 +0300 Subject: [PATCH 1/2] fix(routing): expire stale pending-text + name session from its first message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message typed while no active session exists is stashed in ``context.user_data["_pending_text"]`` and forwarded once a session is created. ``user_data`` is in-memory and only a bot restart clears it, so a stash could survive for hours and then be injected into an unrelated session created much later. Field incident (2026-06-28): a 01:05 "почему смартфон на 100%" message was stashed (no active session after an overnight restart marked all windows lost), delivered to the resumed session via the auto-restore path — which never popped ``_pending_text`` — and then, with no further restart to clear memory, the stale stash was re-forwarded at 10:34 into a brand-new window. That window then caught the user's unrelated "найди сессию про глотание / альфа страховка" message and got auto-named "medical insurance". Fixes: - ``stash_pending_text`` / ``take_pending_text`` (TTL ``PENDING_TEXT_TTL_S`` = 10 min): consumption drops a stale stash and always clears the slot, so a leaked pending message can never re-fire. Tolerates the legacy bare-string format for state in flight across deploy. - ``_session_create`` and ``window_picker`` consume via ``take_pending_text`` (single clearing read) instead of get-then-pop. - ``_session_create`` seeds the auto-name from the forwarded pending text (the session's actual first human message), so a session is named by its first message rather than whatever inbound lands first. ``maybe_auto_name`` stays one-shot via its re-entrancy guard. Adds tests/test_pending_text_ttl.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ccbot/bot/_session_create.py | 20 ++++-- src/ccbot/bot/callbacks/window_picker.py | 7 +- src/ccbot/bot/messages.py | 3 +- src/ccbot/handlers/directory_browser.py | 60 ++++++++++++++++++ tests/test_pending_text_ttl.py | 81 ++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 tests/test_pending_text_ttl.py diff --git a/src/ccbot/bot/_session_create.py b/src/ccbot/bot/_session_create.py index 9ac2259c..a97c1542 100644 --- a/src/ccbot/bot/_session_create.py +++ b/src/ccbot/bot/_session_create.py @@ -11,16 +11,19 @@ from __future__ import annotations +import asyncio import logging from telegram.ext import ContextTypes +from ..handlers.directory_browser import take_pending_text from ..handlers.message_sender import safe_edit, safe_send from ..handlers.notifications import ( detach_paused_cards_at_message, paint_card_on_carrier, ) from ..local_terminal import open_terminal_for_window +from ..naming import maybe_auto_name from ..session import session_manager from ..tmux_manager import tmux_manager @@ -165,16 +168,17 @@ async def create_and_activate_session( if ws.session_id and not sess.claude_session_id: session_manager.set_session_claude_id(sess.id, ws.session_id) - # Forward any pending text held while the picker was up. - pending_text = context.user_data.get("_pending_text") if context.user_data else None + # Forward any pending text held while the picker was up. ``take_pending_text`` + # drops a stale stash (older than PENDING_TEXT_TTL_S) so a message typed + # hours ago against a since-gone session can never be injected here — the + # 2026-06-28 "medical insurance" misroute. + pending_text = take_pending_text(context.user_data) if pending_text: logger.debug( "Forwarding pending text to window %s (len=%d)", created_wname, len(pending_text), ) - if context.user_data is not None: - context.user_data.pop("_pending_text", None) send_ok, send_msg = await session_manager.send_to_window( created_wid, pending_text ) @@ -185,3 +189,11 @@ async def create_and_activate_session( user.id, f"❌ Failed to send pending message: {send_msg}", ) + elif len(pending_text) >= 20: + # Name the session from its actual first human message (this + # pending text), not from whatever inbound happens to land + # first. ``maybe_auto_name`` is one-shot (re-entrancy guard), + # so a later message can't override it. + asyncio.create_task( + maybe_auto_name(sess.id, pending_text, getattr(user, "id", None)) + ) diff --git a/src/ccbot/bot/callbacks/window_picker.py b/src/ccbot/bot/callbacks/window_picker.py index a15e2e53..41b996e6 100644 --- a/src/ccbot/bot/callbacks/window_picker.py +++ b/src/ccbot/bot/callbacks/window_picker.py @@ -24,6 +24,7 @@ UNBOUND_WINDOWS_KEY, build_directory_browser, clear_window_picker_state, + take_pending_text, ) from ...handlers.message_sender import safe_edit, safe_send from ...session import session_manager @@ -77,11 +78,7 @@ async def handle( await safe_edit(query, f"✅ Bound to window `{display}`") - pending_text = ( - context.user_data.get("_pending_text") if context.user_data else None - ) - if context.user_data is not None: - context.user_data.pop("_pending_text", None) + pending_text = take_pending_text(context.user_data) if pending_text: send_ok, send_msg = await session_manager.send_to_window( selected_wid, pending_text diff --git a/src/ccbot/bot/messages.py b/src/ccbot/bot/messages.py index 11a18752..4946115c 100644 --- a/src/ccbot/bot/messages.py +++ b/src/ccbot/bot/messages.py @@ -34,6 +34,7 @@ STATE_SELECTING_SESSION, STATE_SELECTING_WINDOW, build_directory_browser, + stash_pending_text, ) from ..handlers.interactive_ui import ( get_interactive_window, @@ -776,7 +777,7 @@ async def _resolve_active_window( context.user_data[BROWSE_PATH_KEY] = start_path context.user_data[BROWSE_PAGE_KEY] = 0 context.user_data[BROWSE_DIRS_KEY] = subdirs - context.user_data["_pending_text"] = text + stash_pending_text(context.user_data, text) await safe_reply(update.message, msg_text, reply_markup=keyboard) return None diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index 3805c9da..cdda4df5 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -12,6 +12,7 @@ - clear_browse_state: Clear browsing state from user_data """ +import logging import os import time from collections.abc import Callable @@ -55,6 +56,65 @@ SESSIONS_KEY = "cached_sessions" # Cache of ClaudeSession list[Any] SESSIONS_PAGE_KEY = "sessions_page" # current page index in session picker +logger = logging.getLogger(__name__) + +# Pending-text stash: a message the user typed while no active session +# existed is held in user_data until a session is created, then forwarded. +PENDING_TEXT_KEY = "_pending_text" + +# Max age before a stashed pending message is treated as abandoned. +# context.user_data lives in memory and is NOT cleared between messages — +# only a bot restart wipes it. Without an expiry a stash can survive for +# hours and then be injected into an unrelated session created much later +# (the 2026-06-28 "medical insurance" misroute: a 01:05 message resurfaced +# in a 10:34 session). 10 min is generous for a browser/picker flow yet +# kills any multi-hour leak. +PENDING_TEXT_TTL_S = 600.0 + + +def stash_pending_text(user_data: dict[str, Any] | None, text: str) -> None: + """Hold the user's text while a directory/session picker is up. + + Stamped with a timestamp so ``take_pending_text`` can reject a stale + stash. See ``PENDING_TEXT_TTL_S``. + """ + if user_data is not None: + user_data[PENDING_TEXT_KEY] = {"text": text, "ts": time.time()} + + +def take_pending_text( + user_data: dict[str, Any] | None, max_age_s: float | None = PENDING_TEXT_TTL_S +) -> str | None: + """Pop the stashed pending text, returning it only if still fresh. + + Always clears the slot (a leaked stash must never re-fire). Returns + None when absent, empty, or older than ``max_age_s``. Tolerates the + legacy bare-string format (treated as fresh) for state in flight + across a deploy. + """ + if not user_data: + return None + raw = user_data.pop(PENDING_TEXT_KEY, None) + if raw is None: + return None + if isinstance(raw, str): # legacy pre-TTL format + return raw or None + if not isinstance(raw, dict): + return None + text = raw.get("text") + if not text: + return None + age = time.time() - float(raw.get("ts", 0.0)) + if max_age_s is not None and age > max_age_s: + logger.info( + "Dropping stale pending text (age=%.0fs > %.0fs, len=%d)", + age, + max_age_s, + len(text), + ) + return None + return text + def clear_browse_state(user_data: dict[str, Any] | None) -> None: """Clear directory browsing state keys from user_data.""" diff --git a/tests/test_pending_text_ttl.py b/tests/test_pending_text_ttl.py new file mode 100644 index 00000000..cd130771 --- /dev/null +++ b/tests/test_pending_text_ttl.py @@ -0,0 +1,81 @@ +"""Pending-text stash freshness guard (regression for the 2026-06-28 misroute). + +A message typed while no active session existed is held in ``user_data`` +and forwarded once a session is created. ``user_data`` lives in memory and +is only cleared by a bot restart, so without an expiry a stash could +survive for hours and then be injected into an unrelated session created +much later — exactly how a 01:05 "smartphone 100%" message resurfaced in a +10:34 session that then got auto-named "medical insurance". + +These tests pin the TTL contract of ``stash_pending_text`` / +``take_pending_text``. +""" + +import time + +from ccbot.handlers.directory_browser import ( + PENDING_TEXT_KEY, + PENDING_TEXT_TTL_S, + stash_pending_text, + take_pending_text, +) + + +def test_round_trip_fresh() -> None: + ud: dict = {} + stash_pending_text(ud, "hello world") + assert take_pending_text(ud) == "hello world" + # Slot is cleared after taking — a leaked stash must never re-fire. + assert PENDING_TEXT_KEY not in ud + assert take_pending_text(ud) is None + + +def test_stale_is_dropped() -> None: + ud: dict = {} + stash_pending_text(ud, "9.5h old night message") + # Backdate the stamp past the TTL. + ud[PENDING_TEXT_KEY]["ts"] = time.time() - (PENDING_TEXT_TTL_S + 60) + assert take_pending_text(ud) is None + # Even though dropped, the slot is still cleared. + assert PENDING_TEXT_KEY not in ud + + +def test_just_within_ttl_survives() -> None: + ud: dict = {} + stash_pending_text(ud, "recent") + ud[PENDING_TEXT_KEY]["ts"] = time.time() - (PENDING_TEXT_TTL_S - 30) + assert take_pending_text(ud) == "recent" + + +def test_custom_max_age() -> None: + ud: dict = {} + stash_pending_text(ud, "x") + ud[PENDING_TEXT_KEY]["ts"] = time.time() - 5 + assert take_pending_text(ud, max_age_s=1) is None + + +def test_no_expiry_when_max_age_none() -> None: + ud: dict = {} + stash_pending_text(ud, "x") + ud[PENDING_TEXT_KEY]["ts"] = time.time() - 99999 + assert take_pending_text(ud, max_age_s=None) == "x" + + +def test_legacy_bare_string_tolerated() -> None: + # State in flight across the deploy that introduces the dict format. + ud: dict = {PENDING_TEXT_KEY: "legacy text"} + assert take_pending_text(ud) == "legacy text" + assert PENDING_TEXT_KEY not in ud + + +def test_absent_and_empty() -> None: + assert take_pending_text(None) is None + assert take_pending_text({}) is None + assert ( + take_pending_text({PENDING_TEXT_KEY: {"text": "", "ts": time.time()}}) is None + ) + + +def test_stash_none_user_data_is_noop() -> None: + # Must not raise when user_data is unavailable. + stash_pending_text(None, "x") From f36fc17ede1d502fceb92b2b390dd0c7e9b5246a Mon Sep 17 00:00:00 2001 From: Time4Mind Date: Sun, 28 Jun 2026 11:19:08 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(e2e):=20pending-text=20stash=20now=20t?= =?UTF-8?q?imestamped=20=E2=80=94=20read=20via=20take=5Fpending=5Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/test_inbound_routing.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_inbound_routing.py b/tests/e2e/test_inbound_routing.py index 8eee8ed0..3ca555ec 100644 --- a/tests/e2e/test_inbound_routing.py +++ b/tests/e2e/test_inbound_routing.py @@ -85,8 +85,11 @@ async def test_text_with_no_active_session_opens_dir_browser(fake_tmux, fake_bot await text_handler(update, ctx) assert fake_tmux.sent == [] - # The pending text is held for forwarding after session creation. - assert ctx.user_data.get("_pending_text") == "hello there" + # The pending text is held (timestamped) for forwarding after session + # creation; ``take_pending_text`` reads it back with a freshness guard. + from ccbot.handlers.directory_browser import take_pending_text + + assert take_pending_text(ctx.user_data) == "hello there" @pytest.mark.asyncio