Background task notifications routed to wrong session
NousResearch/hermes-agent · cluster 1143 · agent
Data as of
5 open items. Fix merged in #1706. Check it covers your case.
The short answer
- Known?
- Yes. 44 related items grouped here, 15 issues already closed.
- Fix event
- A fix was merged to main in PR #1706. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #1706, #7454, #10460, #60869, #60871, #64593, #65419. That is separate from the fix event. - Still open
- 1 issue and 4 PRs.
Next step: read #1706, then check whether your report is one of the 1 still open.
Read #1706Pick up a Lab taskThese issues and fixes consistently address a problem where async delegate task results, background process notifications, and related events are delivered to the wrong session, thread, or channel. Several fixes involve replacing process-level environment state with context variables to correctly preserve session identity across concurrent operations.
Open issues1
- Issue#65407[Bug]: residual HERMES_SESSION_* readers prefer process env over task ContextVars
Open PRs4
Fixes on record
-
#1706 fix(gateway): persist watcher metadata in checkpoint for crash recovery (salvaged #1573)
files:
gateway/run.py,tests/gateway/test_background_process_notifications.py,tests/tools/test_process_registry.py,tools/process_registry.py,tools/terminal_tool.py -
#7454 fix(gateway): replace os.environ session state with contextvars + fix skill frontmatter truncation
files:
agent/prompt_builder.py,agent/skill_utils.py,gateway/run.py,gateway/session_context.py,tests/gateway/test_session_env.py,tools/cronjob_tools.py,tools/send_message_tool.py,tools/skills_tool.py,tools/terminal_tool.py,tools/tts_tool.pyShow the diff
-
agent/prompt_builder.py@@ -487,7 +487,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]: (True, {}, "") to err on the side of showing the skill. """ try: - raw = skill_file.read_text(encoding="utf-8")[:2000] + raw = skill_file.read_text(encoding="utf-8") frontmatter, _ = parse_frontmatter(raw) if not skill_matches_platform(frontmatter): @@ -495,7 +495,7 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]: return True, frontmatter, extract_skill_description(frontmatter) except Exception as e: - logger.debug("Failed to parse skill file %s: %s", skill_file, e) + logger.warning("Failed to parse skill file %s: %s", skill_file, e) return True, {}, "" @@ -558,9 +558,10 @@ def build_skills_system_prompt( # ── Layer 1: in-process LRU cache ───────────────────────────────── # Include the resolved platform so per-platform disabled-skill lists # produce distinct cache entries (gateway serves multiple platforms). + from gateway.session_context import get_session_env _platform_hint = ( os.environ.get("HERMES_PLATFORM") - or os.environ.get("HERMES_SESSION_PLATFORM") + or get_session_env("HERMES_SESSION_PLATFORM") or "" ) cache_key = ( -
agent/skill_utils.py@@ -145,10 +145,11 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]: if not isinstance(skills_cfg, dict): return set() + from gateway.session_context import get_session_env resolved_platform = ( platform or os.getenv("HERMES_PLATFORM") - or os.getenv("HERMES_SESSION_PLATFORM") + or get_session_env("HERMES_SESSION_PLATFORM") ) if resolved_platform: platform_disabled = (skills_cfg.get("platform_disabled") or {}).get( -
gateway/run.py@@ -2400,8 +2400,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): # Build session context context = build_session_context(source, self.config, session_entry) - # Set environment variables for tools - self._set_session_env(context) + # Set session context variables for tools (task-local, concurrency-safe) + _session_env_tokens = [redacted:secret-assignment] # Read privacy.redact_pii from config (re-read per message) _redact_pii = False @@ -3234,8 +3234,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): "Try again or use /reset to start a fresh session." ) finally: - # Clear session env - self._clear_session_env() + # Restore session context variables to their pre-handler state + self._clear_session_env(_session_env_tokens) def _format_session_info(self) -> str: """Resolve current model config and return a formatted info block. @@ -6120,20 +6120,27 @@ async def _send_update_notification(self) -> bool: return True - def _set_session_env(self, context: SessionContext) -> None: - """Set environment variables for the current session.""" - os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value - os.environ["HERMES_SESSION_CHAT_ID"] = context.source.chat_id - if context.source.chat_name: - os.environ["HERMES_SESSION_CHAT_NAME"] = context.source.chat_name - if context.source.thread_id: - os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id) - - def _clear_session_env(self) -> None: - """Clear session environment variables.""" - for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]: - if var in os.environ: - del os.environ[var] + def _set_session_env(self, context: SessionContext) -> list: + """Set session context variables for the current async task. + + Uses ``contextvars`` instead of ``os.environ`` so that concurrent + gateway messages cannot overwrite each other's session state. + + Returns a list of reset tokens; pass them to ``_clear_session_env`` + in a ``finally`` block. + """ + from gateway.session_context import set_session_vars + return set_session_vars( + platform=context.source.platform.value, + chat_id=context.source.chat_id, + chat_name=context.source.chat_name or "", + thread_id=str(context.source.thread_id) if context.source.thread_id else "", + ) + + def _clear_session_env(self, tokens: list) -> None: + """Restore session context variables to their pre-handler values.""" + from gateway.session_context import clear_session_vars + clear_session_vars(tokens) async def _enrich_message_with_vision( self, -
gateway/session_context.py@@ -0,0 +1,113 @@ +""" +Session-scoped context variables for the Hermes gateway. + +Replaces the previous ``os.environ``-based session state +(``HERMES_SESSION_PLATFORM``, ``HERMES_SESSION_CHAT_ID``, etc.) with +Python's ``contextvars.ContextVar``. + +**Why this matters** + +The gateway processes messages concurrently via ``asyncio``. When two +messages arrive at the same time the old code did: + + os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id) + +Because ``os.environ`` is *process-global*, Message A's value was +silently overwritten by Message B before Message A's agent finished +running. Background-task notifications and tool calls therefore routed +to the wrong thread. + +``contextvars.ContextVar`` values are *task-local*: each ``asyncio`` +task (and any ``run_in_executor`` thread it spawns) gets its own copy, +so concurrent messages never interfere. + +**Backward compatibility** + +The public helper ``get_session_env(name, default="")`` mirrors the old +``os.getenv("HERMES_SESSION_*", ...)`` calls. Existing tool code only +needs to replace the import + call site: + + # before + import os + platform = os.getenv("HERMES_SESSION_PLATFORM", "") + + # after + from gateway.session_context import get_session_env + platform = get_session_env("HERMES_SESSION_PLATFORM", "") +""" + +from contextvars import ContextVar + +# --------------------------------------------------------------------------- +# Per-task session variables +# --------------------------------------------------------------------------- + +_SESSION_PLATFORM: ContextVar[str] = ContextVar("HERMES_SESSION_PLATFORM", default="") +_SESSION_CHAT_ID: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_ID", default="") +_SESSION_CHAT_NAME: ContextVar[str] = ContextVar("HERMES_SESSION_CHAT_NAME", default="") +_SESSION_THREAD_ID: ContextVar[str] = ContextVar("HERMES_SESSION_THREAD_ID", default="") + +_VAR_MAP = { + "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM, + "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID, + "HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME, + "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID, +} + + +def set_session_vars( + platform: str = "", + chat_id: str = "", + chat_name: str = "", + thread_id: str = "", +) -> list: + """Set all session context variables and return reset tokens. + + Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore + the previous values when the handler exits. + + Returns a list of ``Token`` objects (one per variable) that can be + passed to ``clear_session_vars``. + """ + tokens = [ + _SESSION_PLATFORM.set(platform), + _SESSION_CHAT_ID.set(chat_id), + _SESSION_CHAT_NAME.set(chat_name), + _SESSION_THREAD_ID.set(thread_id), + ] + return tokens + + +def clear_session_vars(tokens: list) -> None: + """Restore session context variables to their pre-handler values.""" + if not tokens: + return + vars_in_order = [ + _SESSION_PLATFORM, + _SESSION_CHAT_ID, + _SESSION_CHAT_NAME, + _SESSION_THREAD_ID, + ] + for var, token in zip(vars_in_order, tokens): + var.reset(token) + + +def get_session_env(name: str, default: str = "") -> str: + """Read a session context variable by its legacy ``HERMES_SESSION_*`` name. + + Drop-in replacement for ``os.getenv("HERMES_SESSION_*", default)``. + + Resolution order: + 1. Context variable (set by the gateway for concurrency-safe access) + 2. ``os.environ`` (used by CLI, cron scheduler, and tests) + 3. *default* + """ + import os + + var = _VAR_MAP.get(name) + if var is not None: + value = var.get() + if value: + return value + # Fall back to os.environ for CLI, cron, and test compatibility + return os.getenv(name, default) -
tests/gateway/test_session_env.py@@ -3,9 +3,15 @@ from gateway.config import Platform from gateway.run import GatewayRunner from gateway.session import SessionContext, SessionSource +from gateway.session_context import ( + get_session_env, + set_session_vars, + clear_session_vars, +) -def test_set_session_env_includes_thread_id(monkeypatch): +def test_set_session_env_sets_contextvars(monkeypatch): + """_set_session_env should populate contextvars, not os.environ.""" runner = object.__new__(GatewayRunner) source = SessionSource( platform=Platform.TELEGRAM, @@ -21,25 +27,93 @@ def test_set_session_env_includes_thread_id(monkeypatch): monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) - runner._set_session_env(context) + tokens = [redacted:secret-assignment] - assert os.getenv("HERMES_SESSION_PLATFORM") == "telegram" - assert os.getenv("HERMES_SESSION_CHAT_ID") == "-1001" - assert os.getenv("HERMES_SESSION_CHAT_NAME") == "Group" - assert os.getenv("HERMES_SESSION_THREAD_ID") == "17585" + # Values should be readable via get_session_env (contextvar path) + assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" + assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001" + assert get_session_env("HERMES_SESSION_CHAT_NAME") == "Group" + assert get_session_env("HERMES_SESSION_THREAD_ID") == "17585" + # os.environ should NOT be touched + assert os.getenv("HERMES_SESSION_PLATFORM") is None + assert os.getenv("HERMES_SESSION_THREAD_ID") is None + + # Clean up + runner._clear_session_env(tokens) -def test_clear_session_env_removes_thread_id(monkeypatch): + +def test_clear_session_env_restores_previous_state(monkeypatch): + """_clear_session_env should restore contextvars to their pre-handler values.""" runner = object.__new__(GatewayRunner) - monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") - monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "-1001") - monkeypatch.setenv("HERMES_SESSION_CHAT_NAME", "Group") - monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "17585") + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) + monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) + monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False) + + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1001", + chat_name="Group", + chat_type="group", + thread_id="17585", + ) + context = SessionContext(source=source, connected_platforms=[], home_channels={}) + + tokens = [redacted:secret-assignment] + assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" - runner._clear_session_env() + runner._clear_session_env(tokens) - assert os.getenv("HERMES_SESSION_PLATFORM") is None - assert os.getenv("HERMES_SESSION_CHAT_ID") is None - assert os.getenv("HERMES_SESSION_CHAT_NAME") is None - assert os.getenv("HERMES_SESSION_THREAD_ID") is None + # After clear, contextvars should return to defaults (empty) + assert get_session_env("HERMES_SESSION_PLATFORM") == "" + assert get_session_env("HERMES_SESSION_CHAT_ID") == "" + assert get_session_env("HERMES_SESSION_CHAT_NAME") == "" + assert get_session_env("HERMES_SESSION_THREAD_ID") == "" + + +def test_get_session_env_falls_back_to_os_environ(monkeypatch): + """get_session_env should fall back to os.environ when contextvar is unset.""" + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + + # No contextvar set — should read from os.environ + assert get_session_env("HERMES_SESSION_PLATFORM") == "discord" + + # Now set a contextvar — should prefer it + tokens = [redacted:secret-assignment]"telegram") + assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" + + # Restore — should fall back to os.environ again + clear_session_vars(tokens) + assert get_session_env("HERMES_SESSION_PLATFORM") == "discord" + + +def test_get_session_env_default_when_nothing_set(monkeypatch): + """get_session_env returns default when neither contextvar nor env is set.""" + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + + assert get_session_env("HERMES_SESSION_PLATFORM") == "" + assert get_session_env("HERMES_SESSION_PLATFORM", "fallback") == "fallback" + + +def test_set_session_env_handles_missing_optional_fields(): + """_set_session_env should handle None chat_name and thread_id gracefully.""" + runner = object.__new__(GatewayRunner) + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1001", + chat_name=None, + chat_type="private", + thread_id=None, + ) + context = SessionContext(source=source, connected_platforms=[], home_channels={}) + + tokens = [redacted:secret-assignment] + + assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" + assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001" + assert get_session_env("HERMES_SESSION_CHAT_NAME") == "" + assert get_session_env("HERMES_SESSION_THREAD_ID") == "" + + runner._clear_session_env(tokens) -
tools/cronjob_tools.py@@ -64,14 +64,15 @@ def _scan_cron_prompt(prompt: str) -> str: def _origin_from_env() -> Optional[Dict[str, str]]: - origin_platform = os.getenv("HERMES_SESSION_PLATFORM") - origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID") + from gateway.session_context import get_session_env + origin_platform = get_session_env("HERMES_SESSION_PLATFORM") + origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID") if origin_platform and origin_chat_id: return { "platform": origin_platform, "chat_id": origin_chat_id, - "chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"), - "thread_id": os.getenv("HERMES_SESSION_THREAD_ID"), + "chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None, + "thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None, } return None -
tools/send_message_tool.py@@ -212,7 +212,8 @@ def _handle_send(args): if isinstance(result, dict) and result.get("success") and mirror_text: try: from gateway.mirror import mirror_to_session - source_label = os.getenv("HERMES_SESSION_PLATFORM", "cli") + from gateway.session_context import get_session_env + source_label = get_session_env("HERMES_SESSION_PLATFORM", "cli") if mirror_to_session(platform_name, chat_id, mirror_text, source_label=source_label, thread_id=thread_id): result["mirrored"] = True except Exception: @@ -1020,7 +1021,8 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No def _check_send_message(): """Gate send_message on gateway running (always available on messaging platforms).""" - platform = os.getenv("HERMES_SESSION_PLATFORM", "") + from gateway.session_context import get_session_env + platform = get_session_env("HERMES_SESSION_PLATFORM", "") if platform and platform != "local": return True try: -
tools/skills_tool.py@@ -347,7 +347,8 @@ def _capture_required_environment_variables( def _is_gateway_surface() -> bool: if os.getenv("HERMES_GATEWAY_SESSION"): return True - return bool(os.getenv("HERMES_SESSION_PLATFORM")) + from gateway.session_context import get_session_env + return bool(get_session_env("HERMES_SESSION_PLATFORM")) def _get_terminal_backend_name() -> str: -
tools/terminal_tool.py@@ -1420,10 +1420,11 @@ def terminal_tool( # In gateway mode, auto-register a fast watcher so the # gateway can detect completion and trigger a new agent # turn. CLI mode uses the completion_queue directly. - _gw_platform = os.getenv("HERMES_SESSION_PLATFORM", "") + from gateway.session_context import get_session_env as _gse + _gw_platform = _gse("HERMES_SESSION_PLATFORM", "") if _gw_platform and not check_interval: - _gw_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "") - _gw_thread_id = os.getenv("HERMES_SESSION_THREAD_ID", "") + _gw_chat_id = _gse("HERMES_SESSION_CHAT_ID", "") + _gw_thread_id = _gse("HERMES_SESSION_THREAD_ID", "") proc_session.watcher_platform = _gw_platform proc_session.watcher_chat_id = _gw_chat_id proc_session.watcher_thread_id = _gw_thread_id @@ -1445,9 +1446,10 @@ def terminal_tool( result_data["check_interval_note"] = ( f"Requested {check_interval}s raised to minimum 30s" ) - watcher_platform = os.getenv("HERMES_SESSION_PLATFORM", "") - watcher_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "") - watcher_thread_id = os.getenv("HERMES_SESSION_THREAD_ID", "") + from gateway.session_context import get_session_env as _gse2 + watcher_platform = _gse2("HERMES_SESSION_PLATFORM", "") + watcher_chat_id = _gse2("HERMES_SESSION_CHAT_ID", "") + watcher_thread_id = _gse2("HERMES_SESSION_THREAD_ID", "") # Store on session for checkpoint persistence proc_session.watcher_platform = watcher_platform -
tools/tts_tool.py@@ -480,7 +480,8 @@ def text_to_speech_tool( # Telegram voice bubbles require Opus (.ogg); OpenAI and ElevenLabs can # produce Opus natively (no ffmpeg needed). Edge TTS always outputs MP3 # and needs ffmpeg for conversion. - platform = os.getenv("HERMES_SESSION_PLATFORM", "").lower() + from gateway.session_context import get_session_env + platform = get_session_env("HERMES_SESSION_PLATFORM", "").lower() want_opus = (platform == "telegram") # Determine output path
-
-
#10460 fix(gateway): route watch-pattern notifications to originating session
files:
gateway/run.py,tests/gateway/test_background_process_notifications.py,tests/gateway/test_internal_event_bypass_pairing.py,tests/tools/test_watch_patterns.py,tools/process_registry.py,tools/terminal_tool.pyShow the diff
-
gateway/run.py@@ -482,6 +482,27 @@ def _resolve_hermes_bin() -> Optional[list[str]]: return None +def _parse_session_key(session_key: str) -> "dict | None": + """Parse a session key into its component parts. + + Session keys follow the format + ``agent:main:{platform}:{chat_type}:{chat_id}[:{thread_id}[:{user_id}]]``. + Returns a dict with ``platform``, ``chat_type``, ``chat_id``, and + optionally ``thread_id`` keys, or None if the key doesn't match. + """ + parts = session_key.split(":") + if len(parts) >= 5 and parts[0] == "agent" and parts[1] == "main": + result = { + "platform": parts[2], + "chat_type": parts[3], + "chat_id": parts[4], + } + if len(parts) > 5: + result["thread_id"] = parts[5] + return result + return None + + def _format_gateway_process_notification(evt: dict) -> "str | None": """Format a watch pattern event from completion_queue into a [SYSTEM:] message.""" evt_type = evt.get("type", "completion") @@ -1489,12 +1510,11 @@ async def _notify_active_sessions_of_shutdown(self) -> None: notified: set = set() for session_key in active: # Parse platform + chat_id from the session key. - # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...] - parts = session_key.split(":") - if len(parts) < 5: + _parsed = _parse_session_key(session_key) + if not _parsed: continue - platform_str = parts[2] - chat_id = parts[4] + platform_str = _parsed["platform"] + chat_id = _parsed["chat_id"] # Deduplicate: one notification per chat, even if multiple # sessions (different users/threads) share the same chat. @@ -1510,7 +1530,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None: # Include thread_id if present so the message lands in the # correct forum topic / thread. - thread_id = parts[5] if len(parts) > 5 else None + thread_id = _parsed.get("thread_id") metadata = {"thread_id": thread_id} if thread_id else None await adapter.send(chat_id, msg, metadata=metadata) @@ -3958,7 +3978,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): synth_text = _format_gateway_process_notification(evt) if synth_text: try: - await self._inject_watch_notification(synth_text, event) + await self._inject_watch_notification(synth_text, evt) except Exception as e2: logger.error("Watch notification injection error: %s", e2) except Exception as e: @@ -7452,14 +7472,75 @@ async def _enrich_message_with_transcription( return prefix return user_text - async def _inject_watch_notification(self, synth_text: str, original_event) -> None: + def _build_process_event_source(self, evt: dict): + """Resolve the canonical source for a synthetic background-process event. + + Prefer the persisted session-store origin for the event's session key. + Falling back to the currently active foreground event is what causes + cross-topic bleed, so don't do that. + """ + from gateway.session import SessionSource + + session_key = str(evt.get("session_key") or "").strip() + derived_platform = "" + derived_chat_type = "" + derived_chat_id = "" + + if session_key: + try: + self.session_store._ensure_loaded() + entry = self.session_store._entries.get(session_key) + if entry and getattr(entry, "origin", None): + return entry.origin + except Exception as exc: + logger.debug( + "Synthetic process-event session-store lookup failed for %s: %s", + session_key, + exc, + ) + + _parsed = _parse_session_key(session_key) + if _parsed: + derived_platform = _parsed["platform"] + derived_chat_type = _parsed["chat_type"] + derived_chat_id = _parsed["chat_id"] + + platform_name = str(evt.get("platform") or derived_platform or "").strip().lower() + chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower() + chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip() + if not platform_name or not chat_type or not chat_id: + return None + + try: + platform = Platform(platform_name) + except Exception: + logger.warning( + "Synthetic process event has invalid platform metadata: %r", + platform_name, + ) + return None + + return SessionSource( + platform=platform, + chat_id=chat_id, + chat_type=chat_type, + thread_id=str(evt.get("thread_id") or "").strip() or None, + user_id=str(evt.get("user_id") or "").strip() or None, + user_name=str(evt.get("user_name") or "").strip() or None, + ) + + async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: """Inject a watch-pattern notification as a synthetic message event. - Uses the source from the original user event to route the notification - back to the correct chat/adapter. + Routing must come from the queued watch event itself, not from whatever + foreground message happened to be active when the queue was drained. """ - source = getattr(original_event, "source", None) + source = self._build_process_event_source(evt) if not source: + logger.warning( + "Dropping watch notification with no routing metadata for process %s", + evt.get("session_id", "unknown"), + ) return platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) adapter = None @@ -7477,7 +7558,12 @@ async def _inject_watch_notification(self, synth_text: str, original_event) -> N source=source, internal=True, ) - logger.info("Watch pattern notification — injecting for %s", platform_name) + logger.info( + "Watch pattern notification — injecting for %s chat=%s thread=%s", + platform_name, + source.chat_id, + source.thread_id, + ) await adapter.handle_message(synth_event) except Exception as e: logger.error("Watch notification injection error: %s", e) @@ -7547,33 +7633,42 @@ async def _run_process_watcher(self, watcher: dict) -> None: f"Command: {session.command}\n" f"Output:\n{_out}]" ) + source = self._build_process_event_source({ + "session_id": session_id, + "session_key": session_key, + "platform": platform_name, + "chat_id": chat_id, + "thread_id": thread_id, + "user_id": user_id, + "user_name": user_name, + }) + if not source: + logger.warning( + "Dropping completion notification with no routing metadata for process %s", + session_id, + ) + break + adapter = None for p, a in self.adapters.items(): - if p.value == platform_name: + if p == source.platform: adapter = a break - if adapter and chat_id: + if adapter and source.chat_id: try: from gateway.platforms.base import MessageEvent, MessageType - from gateway.session import SessionSource - from gateway.config import Platform - _platform_enum = Platform(platform_name) - _source = SessionSource( - platform=_platform_enum, - chat_id=chat_id, - thread_id=thread_id or None, - user_id=user_id or None, - user_name=user_name or None, - ) synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, - source=_source, + source=source, internal=True, ) logger.info( - "Process %s finished — injecting agent notification for session %s", - session_id, session_key, + "Process %s finished — injecting agent notification for session %s chat=%s thread=%s", + session_id, + session_key, + source.chat_id, + source.thread_id, ) await adapter.handle_message(synth_event) except Exception as e: -
tests/gateway/test_background_process_notifications.py@@ -14,7 +14,7 @@ import pytest from gateway.config import GatewayConfig, Platform -from gateway.run import GatewayRunner +from gateway.run import GatewayRunner, _parse_session_key # --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner: monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) runner = GatewayRunner(GatewayConfig()) - adapter = SimpleNamespace(send=AsyncMock()) + adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock()) runner.adapters[Platform.TELEGRAM] = adapter return runner @@ -243,3 +243,162 @@ async def _instant_sleep(*_a, **_kw): assert adapter.send.await_count == 1 _, kwargs = adapter.send.call_args assert kwargs["metadata"] is None + + [redacted:email] +async def test_inject_watch_notification_routes_from_session_store_origin(monkeypatch, tmp_path): + from gateway.session import SessionSource + + runner = _build_runner(monkeypatch, tmp_path, "all") + adapter = runner.adapters[Platform.TELEGRAM] + runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( + origin=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="123", + user_name="Emiliyan", + ) + ) + + evt = { + "session_id": "proc_watch", + "session_key": "agent:main:telegram:group:-100:42", + } + + await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt) + + adapter.handle_message.assert_awaited_once() + synth_event = adapter.handle_message.await_args.args[0] + assert synth_event.internal is True + assert synth_event.source.platform == Platform.TELEGRAM + assert synth_event.source.chat_id == "-100" + assert synth_event.source.chat_type == "group" + assert synth_event.source.thread_id == "42" + assert synth_event.source.user_id == "123" + assert synth_event.source.user_name == "Emiliyan" + + +def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path): + runner = _build_runner(monkeypatch, tmp_path, "all") + + evt = { + "session_id": "proc_watch", + "session_key": "agent:main:telegram:group:-100:42", + "platform": "telegram", + "chat_id": "-100", + "thread_id": "42", + "user_id": "123", + "user_name": "Emiliyan", + } + + source = runner._build_process_event_source(evt) + + assert source is not None + assert source.platform == Platform.TELEGRAM + assert source.chat_id == "-100" + assert source.chat_type == "group" + assert source.thread_id == "42" + assert source.user_id == "123" + assert source.user_name == "Emiliyan" + + [redacted:email] +async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path): + """Negative test: watch notification must NOT route to the foreground thread.""" + from gateway.session import SessionSource + + runner = _build_runner(monkeypatch, tmp_path, "all") + adapter = runner.adapters[Platform.TELEGRAM] + + # Session store has the process's original thread (thread 42) + runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( + origin=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="proc_owner", + user_name="alice", + ) + ) + + # The evt dict carries the correct session_key — NOT a foreground event + evt = { + "session_id": "proc_cross_thread", + "session_key": "agent:main:telegram:group:-100:42", + } + + await runner._inject_watch_notification("[SYSTEM: watch match]", evt) + + adapter.handle_message.assert_awaited_once() + synth_event = adapter.handle_message.await_args.args[0] + # Must route to thread 42 (process origin), NOT some other thread + assert synth_event.source.thread_id == "42" + assert synth_event.source.user_id == "proc_owner" + + +def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path): + """Missing session_key and no platform metadata → None (drop notification).""" + runner = _build_runner(monkeypatch, tmp_path, "all") + + source = runner._build_process_event_source({"session_id": "proc_orphan"}) + assert source is None + + +def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path): + """Invalid platform string → None.""" + runner = _build_runner(monkeypatch, tmp_path, "all") + + evt = { + "session_id": "proc_bad", + "platform": "not_a_real_platform", + "chat_type": "dm", + "chat_id": "123", + } + source = runner._build_process_event_source(evt) + assert source is None + + +def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path): + """Session key with <5 parts doesn't parse, falls through to empty metadata → None.""" + runner = _build_runner(monkeypatch, tmp_path, "all") + + evt = { + "session_id": "proc_short", + "session_key": "agent:main:telegram", # Too few parts + } + source = runner._build_process_event_source(evt) + assert source is None + + +# --------------------------------------------------------------------------- +# _parse_session_key helper +# --------------------------------------------------------------------------- + +def test_parse_session_key_valid(): + result = _parse_session_key("agent:main:telegram:group:-100") + assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "-100"} + + +def test_parse_session_key_with_extra_parts(): + """Thread ID (6th part) is extracted; further parts are ignored.""" + result = _parse_session_key("agent:main:discord:group:chan123:thread456") + assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123", "thread_id": "thread456"} + + +def test_parse_session_key_with_user_id_part(): + """7th part (user_id) is ignored — only up to thread_id is extracted.""" + result = _parse_session_key("agent:main:telegram:group:chat1:thread42:user99") + assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1", "thread_id": "thread42"} + + +def test_parse_session_key_too_short(): + assert _parse_session_key("agent:main:telegram") is None + assert _parse_session_key("") is None + + +def test_parse_session_key_wrong_prefix(): + assert _parse_session_key("cron:main:telegram:dm:123") is None + assert _parse_session_key("agent:cron:telegram:dm:123") is None -
tests/gateway/test_internal_event_bypass_pairing.py@@ -230,6 +230,59 @@ async def _instant_sleep(*_a, **_kw): assert event.source.user_name == "alice" [redacted:email] +async def test_notify_on_complete_uses_session_store_origin_for_group_topic(monkeypatch, tmp_path): + import tools.process_registry as pr_module + from gateway.session import SessionSource + + sessions = [ + SimpleNamespace( + output_buffer="done\n", exited=True, exit_code=0, command="echo test" + ), + ] + monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions)) + + async def _instant_sleep(*_a, **_kw): + pass + monkeypatch.setattr(asyncio, "sleep", _instant_sleep) + + runner = GatewayRunner(GatewayConfig()) + adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock()) + runner.adapters[Platform.TELEGRAM] = adapter + runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace( + origin=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="user-42", + user_name="alice", + ) + ) + + watcher = { + "session_id": "proc_test_internal", + "check_interval": 0, + "session_key": "agent:main:telegram:group:-100:42", + "platform": "telegram", + "chat_id": "-100", + "thread_id": "42", + "notify_on_complete": True, + } + + await runner._run_process_watcher(watcher) + + assert adapter.handle_message.await_count == 1 + event = adapter.handle_message.await_args.args[0] + assert event.internal is True + assert event.source.platform == Platform.TELEGRAM + assert event.source.chat_id == "-100" + assert event.source.chat_type == "group" + assert event.source.thread_id == "42" + assert event.source.user_id == "user-42" + assert event.source.user_name == "alice" + + @pytest.mark.asyncio async def test_none_user_id_skips_pairing(monkeypatch, tmp_path): """A non-internal event with user_id=None should be silently dropped.""" -
tests/tools/test_watch_patterns.py@@ -92,6 +92,25 @@ def test_basic_match(self, registry): assert "disk full" in evt["output"] assert evt["session_id"] == "proc_test_watch" + def test_match_carries_session_key_and_watcher_routing_metadata(self, registry): + session = _make_session(watch_patterns=["ERROR"]) + session.session_key = "agent:main:telegram:group:-100:42" + session.watcher_platform = "telegram" + session.watcher_chat_id = "-100" + session.watcher_user_id = "u123" + session.watcher_user_name = "alice" + session.watcher_thread_id = "42" + + registry._check_watch_patterns(session, "ERROR: disk full\n") + evt = registry.completion_queue.get_nowait() + + assert evt["session_key"] == "agent:main:telegram:group:-100:42" + assert evt["platform"] == "telegram" + assert evt["chat_id"] == "-100" + assert evt["user_id"] == "u123" + assert evt["user_name"] == "alice" + assert evt["thread_id"] == "42" + def test_multiple_patterns(self, registry): """First matching pattern is reported.""" session = _make_session(watch_patterns=["WARN", "ERROR"]) -
tools/process_registry.py@@ -191,9 +191,15 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None: session._watch_disabled = True self.completion_queue.put({ "session_id": session.id, + "session_key": session.session_key, "command": session.command, "type": "watch_disabled", "suppressed": session._watch_suppressed, + "platform": session.watcher_platform, + "chat_id": session.watcher_chat_id, + "user_id": session.watcher_user_id, + "user_name": session.watcher_user_name, + "thread_id": session.watcher_thread_id, "message": ( f"Watch patterns disabled for process {session.id} — " f"too many matches ({session._watch_suppressed} suppressed). " @@ -219,11 +225,17 @@ def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None: self.completion_queue.put({ "session_id": session.id, + "session_key": session.session_key, "command": session.command, "type": "watch_match", "pattern": matched_pattern, "output": output, "suppressed": suppressed, + "platform": session.watcher_platform, + "chat_id": session.watcher_chat_id, + "user_id": session.watcher_user_id, + "user_name": session.watcher_user_name, + "thread_id": session.watcher_thread_id, }) @staticmethod -
tools/terminal_tool.py@@ -1384,14 +1384,10 @@ def terminal_tool( if pty_disabled_reason: result_data["pty_note"] = pty_disabled_reason - # Mark for agent notification on completion - if notify_on_complete and background: - proc_session.notify_on_complete = True - result_data["notify_on_complete"] = True - - # In gateway mode, auto-register a fast watcher so the - # gateway can detect completion and trigger a new agent - # turn. CLI mode uses the completion_queue directly. + # Populate routing metadata on the session so that + # watch-pattern and completion notifications can be + # routed back to the correct chat/thread. + if background and (notify_on_complete or watch_patterns): from gateway.session_context import get_session_env as _gse _gw_platform = _gse("HERMES_SESSION_PLATFORM", "") if _gw_platform: @@ -1404,16 +1400,26 @@ def terminal_tool( proc_session.watcher_user_id = _gw_user_id proc_session.watcher_user_name = _gw_user_name proc_session.watcher_thread_id = _gw_thread_id + + # Mark for agent notification on completion + if notify_on_complete and background: + proc_session.notify_on_complete = True + result_data["notify_on_complete"] = True + + # In gateway mode, auto-register a fast watcher so the + # gateway can detect completion and trigger a new agent + # turn. CLI mode uses the completion_queue directly. + if proc_session.watcher_platform: proc_session.watcher_interval = 5 process_registry.pending_watchers.append({ "session_id": proc_session.id, "check_interval": 5, "session_key": session_key, - "platform": _gw_platform, - "chat_id": _gw_chat_id, - "user_id": _gw_user_id, - "user_name": _gw_user_name, - "thread_id": _gw_thread_id, + "platform": proc_session.watcher_platform, + "chat_id": proc_session.watcher_chat_id, + "user_id": proc_session.watcher_user_id, + "user_name": proc_session.watcher_user_name, + "thread_id": proc_session.watcher_thread_id, "notify_on_complete": True, })
-
-
#37099 feat(desktop): session hygiene, archive, media streaming + connecting overlay
files:
apps/desktop/electron/main.cjs,apps/desktop/src/app/chat/index.tsx,apps/desktop/src/app/chat/sidebar/index.tsx,apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx,apps/desktop/src/app/chat/sidebar/session-row.tsx,apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx,apps/desktop/src/app/command-center/index.tsx,apps/desktop/src/app/desktop-controller.tsx,apps/desktop/src/app/session/hooks/use-session-actions.ts,apps/desktop/src/app/settings/constants.ts,apps/desktop/src/app/settings/index.tsx,apps/desktop/src/app/settings/sessions-settings.tsx,apps/desktop/src/app/settings/types.ts,apps/desktop/src/components/assistant-ui/markdown-text.tsx,apps/desktop/src/components/gateway-connecting-overlay.tsx,apps/desktop/src/hermes.ts,apps/desktop/src/lib/icons.ts,apps/desktop/src/lib/media.ts,apps/desktop/src/types/hermes.ts,hermes_cli/web_server.py(+6 more)Show the diff
-
apps/desktop/electron/main.cjs@@ -8,6 +8,8 @@ const { ipcMain, nativeImage, nativeTheme, + net: electronNet, + protocol, safeStorage, session, shell, @@ -364,6 +366,66 @@ app.setAboutPanelOptions({ copyright: 'Copyright © 2026 Nous Research' }) +// Custom scheme for streaming local media (video/audio) into the renderer. +// Reading large media through `readFileDataUrl` failed: it base64-loads the +// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB), +// so any non-trivial video silently refused to load. Streaming via a protocol +// handler removes the size cap and gives the <video> element seekable, +// range-aware playback. Must be registered before the app is ready. +const MEDIA_PROTOCOL = 'hermes-media' +// Only audio/video may be streamed. Without this the handler would read any +// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`. +const STREAMABLE_MEDIA_EXTS = new Set([ + '.avi', + '.flac', + '.m4a', + '.mkv', + '.mov', + '.mp3', + '.mp4', + '.ogg', + '.opus', + '.wav', + '.webm' +]) + +protocol.registerSchemesAsPrivileged([ + { + scheme: MEDIA_PROTOCOL, + privileges: { + secure: true, + standard: true, + stream: true, + supportFetchAPI: true + } + } +]) + +function registerMediaProtocol() { + protocol.handle(MEDIA_PROTOCOL, async request => { + let resolvedPath + try { + const url = new URL(request.url) + const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, '')) + ;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' })) + } catch { + return new Response('Media not found', { status: 404 }) + } + + if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) { + return new Response('Unsupported media type', { status: 415 }) + } + + // Delegate to Electron's net stack on a file:// URL — it resolves the + // content-type and honors Range requests so seeking works. Forward the + // renderer's headers (notably Range) and skip custom-protocol re-entry. + return electronNet.fetch(pathToFileURL(resolvedPath).toString(), { + bypassCustomProtocolHandlers: true, + headers: request.headers + }) + }) +} + let mainWindow = null let hermesProcess = null let connectionPromise = null @@ -3654,6 +3716,7 @@ app.whenReady().then(() => { Menu.setApplicationMenu(null) } installMediaPermissions() + registerMediaProtocol() ensureWslWindowsFonts() createWindow() -
apps/desktop/src/app/chat/index.tsx@@ -97,7 +97,7 @@ function ChatHeader({ const sessions = useStore($sessions) const pinnedSessionIds = useStore($pinnedSessionIds) const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null - const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent' + const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session' const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false return ( -
apps/desktop/src/app/chat/sidebar/index.tsx@@ -67,7 +67,12 @@ import { VirtualSessionList } from './virtual-session-list' const VIRTUALIZE_THRESHOLD = 25 const SIDEBAR_NAV: SidebarNavItem[] = [ - { id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' }, + { + id: 'new-session', + label: 'New session', + icon: props => <Codicon name="robot" {...props} />, + action: 'new-session' + }, { id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE }, { id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE }, { id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE } @@ -149,6 +154,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> { onLoadMoreSessions: () => void onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void + onArchiveSession: (sessionId: string) => void + onNewSessionInWorkspace: (path: null | string) => void } export function ChatSidebar({ @@ -156,7 +163,9 @@ export function ChatSidebar({ onNavigate, onLoadMoreSessions, onResumeSession, - onDeleteSession + onDeleteSession, + onArchiveSession, + onNewSessionInWorkspace }: ChatSidebarProps) { const sidebarOpen = useStore($sidebarOpen) const agentsGrouped = useStore($sidebarAgentsGrouped) @@ -328,6 +337,7 @@ export function ChatSidebar({ dndSensors={dndSensors} emptyState={<SidebarPinnedEmptyState />} label="Pinned" + onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} onReorder={handlePinnedDragEnd} onResumeSession={onResumeSession} @@ -361,9 +371,9 @@ export function ChatSidebar({ groups={agentsGrouped ? agentGroups : undefined} headerAction={ <Button - aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'} + aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'} className={cn( - 'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100', + 'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100', agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100' )} onClick={event => { @@ -372,15 +382,17 @@ export function ChatSidebar({ setSidebarAgentsGrouped(!agentsGrouped) }} size="icon-xs" - title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'} + title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'} variant="ghost" > <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" /> </Button> } - label="Agents" + label="Sessions" labelMeta={countLabel(agentSessions.length, knownSessionTotal)} + onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} + onNewSessionInWorkspace={onNewSessionInWorkspace} onReorder={handleAgentDragEnd} onResumeSession={onResumeSession} onToggle={() => setSidebarRecentsOpen(!agentsOpen)} @@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps { workingSessionIdSet: Set<string> onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void + onArchiveSession: (sessionId: string) => void onTogglePin: (sessionId: string) => void + onNewSessionInWorkspace?: (path: null | string) => void pinned: boolean rootClassName?: string contentClassName?: string @@ -496,7 +510,9 @@ function SidebarSessionsSection({ workingSessionIdSet, onResumeSession, onDeleteSession, + onArchiveSession, onTogglePin, + onNewSessionInWorkspace, pinned, rootClassName, contentClassName, @@ -518,6 +534,7 @@ function SidebarSessionsSection({ isPinned: pinned, isSelected: session.id === activeSessionId, isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), onPin: () => onTogglePin(session.id), onResume: () => onResumeSession(session.id), @@ -551,9 +568,19 @@ function SidebarSessionsSection({ } else if (groups?.length) { const groupNodes = groups.map(group => dndActive ? ( - <SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} /> + <SortableSidebarWorkspaceGroup + group={group} + key={group.id} + onNewSession={onNewSessionInWorkspace} + renderRows={renderSessionList} + /> ) : ( - <SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} /> + <SidebarWorkspaceGroup + group={group} + key={group.id} + onNewSession={onNewSessionInWorkspace} + renderRows={renderSessionList} + /> ) ) @@ -568,6 +595,7 @@ function SidebarSessionsSection({ inner = ( <VirtualSessionList activeSessionId={activeSessionId} + onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} onResumeSession={onResumeSession} onTogglePin={onTogglePin} @@ -610,6 +638,7 @@ function SidebarSessionsSection({ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> { group: SidebarSessionGroup renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void reorderable?: boolean dragging?: boolean dragHandleProps?: React.HTMLAttributes<HTMLElement> @@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> { function SidebarWorkspaceGroup({ group, renderRows, + onNewSession, reorderable = false, dragging = false, dragHandleProps, @@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({ return ( <div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}> - <button - className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)" - onClick={() => setOpen(value => !value)} - title={group.path ?? undefined} - type="button" - > - <span className="truncate">{group.label}</span> - <SidebarCount>{group.sessions.length}</SidebarCount> - <DisclosureCaret - className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100" - open={open} - /> + <div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)"> + <button + className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)" + onClick={() => setOpen(value => !value)} + title={group.path ?? undefined} + type="button" + > + <span className="truncate">{group.label}</span> + <SidebarCount>{group.sessions.length}</SidebarCount> + <DisclosureCaret + className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100" + open={open} + /> + </button> + {onNewSession && ( + <button + aria-label={`New session in ${group.label}`} + className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100" + onClick={() => onNewSession(group.path)} + title={`New session in ${group.label}`} + type="button" + > + <Codicon name="add" size="0.75rem" /> + </button> + )} {reorderable && ( <span {...dragHandleProps} @@ -663,7 +706,7 @@ function SidebarWorkspaceGroup({ /> </span> )} - </button> + </div> {open && ( <> {renderRows(visibleSessions)} @@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({ interface SortableWorkspaceProps { group: SidebarSessionGroup renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void } function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) { @@ -702,6 +746,7 @@ interface SortableSessionRowProps { isPinned: boolean isSelected: boolean isWorking: boolean + onArchive: () => void onDelete: () => void onPin: () => void onResume: () => void -
apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx@@ -26,6 +26,7 @@ interface SessionActions { title: string pinned?: boolean onPin?: () => void + onArchive?: () => void onDelete?: () => void } @@ -40,7 +41,7 @@ interface ItemSpec { variant?: 'destructive' } -function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) { +function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) { const [renameOpen, setRenameOpen] = useState(false) const items: ItemSpec[] = [ @@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete } setRenameOpen(true) } }, + { + disabled: !onArchive, + icon: 'archive', + label: 'Archive', + onSelect: () => { + triggerHaptic('selection') + onArchive?.() + } + }, { className: 'text-destructive focus:text-destructive', disabled: !onDelete, -
apps/desktop/src/app/chat/sidebar/session-row.tsx@@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> { isPinned: boolean isSelected: boolean isWorking: boolean + onArchive: () => void onDelete: () => void onPin: () => void onResume: () => void @@ -45,6 +46,7 @@ export function SidebarSessionRow({ isPinned, isSelected, isWorking, + onArchive, onDelete, onPin, onResume, @@ -61,7 +63,14 @@ export function SidebarSessionRow({ const handleLabel = `Reorder ${title}` return ( - <SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}> + <SessionContextMenu + onArchive={onArchive} + onDelete={onDelete} + onPin={onPin} + pinned={isPinned} + sessionId={session.id} + title={title} + > <div className={cn( 'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none', @@ -88,6 +97,15 @@ export function SidebarSessionRow({ return } + if (event.metaKey || event.ctrlKey) { + event.preventDefault() + event.stopPropagation() + triggerHaptic('selection') + onArchive() + + return + } + onResume() }} type="button" @@ -127,7 +145,14 @@ export function SidebarSessionRow({ {age} </span> )} - <SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}> + <SessionActionsMenu + onArchive={onArchive} + onDelete={onDelete} + onPin={onPin} + pinned={isPinned} + sessionId={session.id} + title={title} + > <Button aria-label={`Actions for ${title}`} className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!" -
apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx@@ -12,6 +12,7 @@ interface SessionRowCommonProps { isPinned: boolean isSelected: boolean isWorking: boolean + onArchive: () => void onDelete: () => void onPin: () => void onResume: () => void @@ -20,6 +21,7 @@ interface SessionRowCommonProps { interface VirtualSessionListProps { activeSessionId: null | string className?: string + onArchiveSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void onResumeSession: (sessionId: string) => void onTogglePin: (sessionId: string) => void @@ -35,6 +37,7 @@ const OVERSCAN_ROWS = 12 export const VirtualSessionList: FC<VirtualSessionListProps> = ({ activeSessionId, className, + onArchiveSession, onDeleteSession, onResumeSession, onTogglePin, @@ -72,6 +75,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({ isPinned: pinned, isSelected: session.id === activeSessionId, isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), onPin: () => onTogglePin(session.id), onResume: () => onResumeSession(session.id) -
apps/desktop/src/app/command-center/index.tsx@@ -113,7 +113,7 @@ interface SectionSearchEntry { } const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [ - { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' }, + { id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' }, { id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' }, { id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' }, { -
apps/desktop/src/app/desktop-controller.tsx@@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're import { BootFailureOverlay } from '@/components/boot-failure-overlay' import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' +import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' import { Pane, PaneMain } from '@/components/pane-shell' import { useSkinCommand } from '@/themes/use-skin-command' @@ -33,6 +34,8 @@ import { $selectedStoredSessionId, setAwaitingResponse, setBusy, + setCurrentBranch, + setCurrentCwd, setCurrentModel, setCurrentProvider, setMessages, @@ -122,6 +125,7 @@ export function DesktopController() { settingsOpen, toggleCommandCenter } = useOverlayRouting() + const terminalTakeoverActive = chatOpen && terminalTakeover const titlebarToolGroups = useGroupRegistry<TitlebarTool>() @@ -192,7 +196,10 @@ export function DesktopController() { try { const limit = $sessionsLimit.get() - const result = await listSessions(limit) + // Require at least one message so abandoned/empty "Untitled" drafts (one + // was created per TUI/desktop launch before the lazy-create fix) don't + // clutter the sidebar. + const result = await listSessions(limit, 1) if (refreshSessionsRequestRef.current === requestId) { setSessions(result.sessions) @@ -324,6 +331,7 @@ export function DesktopController() { }) const { + archiveSession, branchCurrentSession, createBackendSessionForSend, openSettings, @@ -392,6 +400,29 @@ export function DesktopController() { [branchCurrentSession, refreshSessions] ) + const startSessionInWorkspace = useCallback( + (path: null | string) => { + startFreshSessionDraft() + + const target = path?.trim() + + if (!target) { + return + } + + // The next message creates the backend session in $currentCwd, so seed + // it (and the branch) from the workspace the user clicked the + on. + setCurrentCwd(target) + void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) + .then(info => { + setCurrentCwd(info.cwd || target) + setCurrentBranch(info.branch || '') + }) + .catch(() => undefined) + }, + [requestGateway, startFreshSessionDraft] + ) + const handleSkinCommand = useSkinCommand() const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } = @@ -461,9 +492,11 @@ export function DesktopController() { const sidebar = ( <ChatSidebar currentView={currentView} + onArchiveSession={sessionId => void archiveSession(sessionId)} onDeleteSession={sessionId => void removeSession(sessionId)} onLoadMoreSessions={loadMoreSessions} onNavigate={selectSidebarItem} + onNewSessionInWorkspace={startSessionInWorkspace} onResumeSession={sessionId => navigate(sessionRoute(sessionId))} /> ) @@ -485,6 +518,7 @@ export function DesktopController() { /> <ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} /> <UpdatesOverlay /> + <GatewayConnectingOverlay /> <BootFailureOverlay /> {settingsOpen && ( @@ -575,10 +609,10 @@ export function DesktopController() { titlebarTools={titlebarToolGroups.flat.right} > <Pane + disabled={terminalTakeoverActive} id="chat-sidebar" maxWidth={SIDEBAR_MAX_WIDTH} minWidth={SIDEBAR_DEFAULT_WIDTH} - disabled={terminalTakeoverActive} resizable side="left" width={`${SIDEBAR_DEFAULT_WIDTH}px`} -
apps/desktop/src/app/session/hooks/use-session-actions.ts@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react' import { useCallback, useRef } from 'react' import type { NavigateFunction } from 'react-router-dom' -import { deleteSession, getSessionMessages } from '@/hermes' +import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images' @@ -751,7 +751,39 @@ export function useSessionActions({ ] ) + const archiveSession = useCallback( + async (storedSessionId: string) => { + clearNotifications() + + const archived = $sessions.get().find(s => s.id === storedSessionId) + const wasSelected = selectedStoredSessionId === storedSessionId + const previousPinned = $pinnedSessionIds.get() + + // Soft-hide: drop from the sidebar immediately, keep the data. + setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + $pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId)) + + if (wasSelected) { + startFreshSessionDraft(true) + } + + try { + await setSessionArchived(storedSessionId, true) + notify({ durationMs: 2_000, kind: 'success', message: 'Archived' }) + } catch (err) { + if (archived) { + setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)]) + } + + $pinnedSessionIds.set(previousPinned) + notifyError(err, 'Archive failed') + } + }, + [selectedStoredSessionId, startFreshSessionDraft] + ) + return { + archiveSession, branchCurrentSession, closeSettings, createBackendSessionForSend, -
apps/desktop/src/app/settings/constants.ts@@ -311,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [ { id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor } ] -export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = { +export const SEARCH_PLACEHOLDER: Record< + 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools', + string +> = { about: 'About Hermes Desktop', config: 'Search settings...', gateway: 'Gateway connection...', keys: 'Search API keys...', mcp: 'Search MCP servers...', + sessions: 'Search archived sessions...', tools: 'Search skills and tools...' } -
apps/desktop/src/app/settings/index.tsx@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons' +import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -19,6 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KeysSettings } from './keys-settings' import { McpSettings } from './mcp-settings' +import { SessionsSettings } from './sessions-settings' import { ToolsSettings } from './tools-settings' import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types' @@ -27,6 +28,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'gateway', 'keys', 'mcp', + 'sessions', 'tools', 'about' ] @@ -40,6 +42,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr gateway: '', keys: '', mcp: '', + sessions: '', tools: '' }) @@ -149,6 +152,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr label="MCP" onClick={() => setActiveView('mcp')} /> + <OverlayNavItem + active={activeView === 'sessions'} + icon={Archive} + label="Archived Chats" + onClick={() => setActiveView('sessions')} + /> <div className="my-2 h-px bg-border/30" /> <OverlayNavItem active={activeView === 'about'} @@ -200,6 +209,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr <KeysSettings query={queries.keys} /> ) : activeView === 'mcp' ? ( <McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} /> + ) : activeView === 'sessions' ? ( + <SessionsSettings query={queries.sessions} /> ) : ( <ToolsSettings query={queries.tools} /> )} -
apps/desktop/src/app/settings/sessions-settings.tsx@@ -0,0 +1,168 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { deleteSession, listSessions, setSessionArchived } from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import { setSessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives' +import type { SearchProps } from './types' + +const ARCHIVED_FETCH_LIMIT = 200 + +function workspaceLabel(cwd: null | string | undefined): string { + const path = cwd?.trim() + + if (!path) { + return '' + } + + return ( + path + .replace(/[/\\]+$/, '') + .split(/[/\\]/) + .filter(Boolean) + .pop() ?? path + ) +} + +export function SessionsSettings({ query }: SearchProps) { + const [sessions, setLocalSessions] = useState<SessionInfo[]>([]) + const [loading, setLoading] = useState(true) + const [busyId, setBusyId] = useState<string | null>(null) + + const load = useCallback(async () => { + setLoading(true) + + try { + const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only') + setLocalSessions(result.sessions) + } catch (err) { + notifyError(err, 'Could not load archived sessions') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + const unarchive = useCallback(async (session: SessionInfo) => { + setBusyId(session.id) + + try { + await setSessionArchived(session.id, false) + setLocalSessions(prev => prev.filter(s => s.id !== session.id)) + // Surface it again in the sidebar without waiting for a full refresh. + setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)]) + triggerHaptic('selection') + notify({ durationMs: 2_000, kind: 'success', message: 'Restored' }) + } catch (err) { + notifyError(err, 'Unarchive failed') + } finally { + setBusyId(null) + } + }, []) + + const remove = useCallback(async (session: SessionInfo) => { + if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) { + return + } + + setBusyId(session.id) + + try { + await deleteSession(session.id) + setLocalSessions(prev => prev.filter(s => s.id !== session.id)) + triggerHaptic('warning') + } catch (err) { + notifyError(err, 'Delete failed') + } finally { + setBusyId(null) + } + }, []) + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase() + + if (!needle) { + return sessions + } + + return sessions.filter(session => + [sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle) + ) + }, [query, sessions]) + + if (loading) { + return <LoadingState label="Loading archived sessions…" /> + } + + return ( + <SettingsContent> + <SectionHeading + icon={Archive} + meta={sessions.length ? String(sessions.length) : undefined} + title="Archived sessions" + /> + <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)"> + Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to + archive it. + </p> + + {filtered.length === 0 ? ( + <EmptyState + description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'} + title="Nothing archived" + /> + ) : ( + <div className="divide-y divide-border/30"> + {filtered.map(session => { + const label = workspaceLabel(session.cwd) + const busy = busyId === session.id + + return ( + <ListRow + action={ + <div className="flex items-center gap-1.5"> + <Button + disabled={busy} + onClick={() => void unarchive(session)} + size="sm" + type="button" + variant="outline" + > + {busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />} + <span>Unarchive</span> + </Button> + <Button + aria-label="Delete permanently" + className="text-muted-foreground hover:text-destructive" + disabled={busy} + onClick={() => void remove(session)} + size="icon" + title="Delete permanently" + type="button" + variant="ghost" + > + <Trash2 className="size-3.5" /> + </Button> + </div> + } + description={session.preview || undefined} + hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`} + key={session.id} + title={sessionTitle(session)} + /> + ) + })} + </div> + )} + </SettingsContent> + ) +} -
apps/desktop/src/app/settings/types.ts@@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes' import type { IconComponent } from '@/lib/icons' import type { EnvVarInfo } from '@/types/hermes' -export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}` -export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools' +export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}` +export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>> export interface SettingsPageProps { -
apps/desktop/src/components/assistant-ui/markdown-text.tsx@@ -19,9 +19,9 @@ import { filePathFromMediaPath, mediaExternalUrl, mediaKind, - mediaMime, mediaName, - mediaPathFromMarkdownHref + mediaPathFromMarkdownHref, + mediaStreamUrl } from '@/lib/media' import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { cn } from '@/lib/utils' @@ -40,24 +40,22 @@ import { cn } from '@/lib/utils' // LLM convention). The default false-setting only accepts `$$...$$`. const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) -async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> { - const blob = await fetch(dataUrl).then(response => response.blob()) - - return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime })) -} - async function mediaSrc(path: string): Promise<string> { if (/^(?:https?|data):/i.test(path)) { return path } + // Stream audio/video through the custom protocol: data URLs are capped and + // load the whole file into memory, which broke playback for larger videos. + if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) { + return mediaStreamUrl(path) + } + if (!window.hermesDesktop?.readFileDataUrl) { return mediaExternalUrl(path) } - const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) - - return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl + return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) } function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) { @@ -278,10 +276,7 @@ const MarkdownTextImpl = () => { // render, which churns Streamdown's outer memo + propagates new prop // identities into every Block. The plugin set really only varies on // `isStreaming`, so memoize on that. - const plugins = useMemo( - () => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), - [isStreaming] - ) + const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming]) const components = useMemo( () => -
apps/desktop/src/components/gateway-connecting-overlay.tsx@@ -0,0 +1,183 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' +import { $desktopBoot } from '@/store/boot' +import { $gatewayState } from '@/store/session' + +// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at +// the render level means no timer logic (even a stale HMR one) can ever +// scramble "CONN". +const PREFIX = 'CONN' +const TAIL = 'ECTING' +// Even-weight mono ascii so cycling glyphs don't jump width (matches the +// nousnet-web download-button decode effect). +const SCRAMBLE_CHARS = '/\\|-_=+<>~:*' +const TICK_MS = 45 + +// Exit choreography (ms): text fades down + out, hold, then the overlay fades. +const TEXT_OUT_MS = 360 +const POST_TEXT_HOLD_MS = 300 +const OVERLAY_OUT_MS = 520 +// Preview-only: how long to "connect" for, and the pause before replaying. +const PREVIEW_CONNECT_MS = 2600 +const PREVIEW_REPLAY_MS = 1100 + +type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone' + +// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay +// only flashes. Load with `?connecting=1` to force a looping preview. +function forcedPreview(): boolean { + if (!import.meta.env.DEV || typeof window === 'undefined') { + return false + } + + try { + return new URLSearchParams(window.location.search).get('connecting') === '1' + } catch { + return false + } +} + +function scrambledTail(resolvedCount: number): string { + return Array.from(TAIL, (ch, i) => + i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0] + ).join('') +} + +export function GatewayConnectingOverlay() { + const gatewayState = useStore($gatewayState) + const boot = useStore($desktopBoot) + const [previewing] = useState(forcedPreview) + const [tail, setTail] = useState(TAIL) + const [phase, setPhase] = useState<Phase>('live') + + const connecting = gatewayState !== 'open' && !boot.error + // Latches once we've actually shown the overlay, so the brief frame where + // gatewayState flips to "open" (connecting -> false) before the exit phase + // kicks in doesn't unmount us and cause a flash. + const shownRef = useRef(false) + + if (previewing || connecting) { + shownRef.current = true + } + + // Decode loop — only while live (freeze the resolved word during the exit). + useEffect(() => { + if (phase !== 'live' || (!previewing && !connecting)) { + return + } + + let resolved = 0 + let hold = 0 + + const id = window.setInterval(() => { + if (resolved >= TAIL.length) { + hold += 1 + + if (hold > 16) { + resolved = 0 + hold = 0 + } + + setTail(TAIL) + + return + } + + resolved += 0.5 + setTail(scrambledTail(Math.floor(resolved))) + }, TICK_MS) + + return () => window.clearInterval(id) + }, [phase, previewing, connecting]) + + // Kick off the exit when connected: real connect, or a faked timer in preview. + useEffect(() => { + if (phase !== 'live') { + return + } + + if (previewing) { + const id = window.setTimeout(() => { + setTail(TAIL) + setPhase('text-out') + }, PREVIEW_CONNECT_MS) + + return () => window.clearTimeout(id) + } + + if (gatewayState === 'open' && shownRef.current) { + setTail(TAIL) + setPhase('text-out') + } + }, [phase, previewing, gatewayState]) + + // Advance the exit choreography: text-out -> overlay-out -> gone. + useEffect(() => { + if (phase === 'text-out') { + const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS) + + return () => window.clearTimeout(id) + } + + if (phase === 'overlay-out') { + const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS) + + return () => window.clearTimeout(id) + } + + // Preview replays so we can keep watching the transition. + if (phase === 'gone' && previewing) { + const id = window.setTimeout(() => { + setTail(TAIL) + setPhase('live') + }, PREVIEW_REPLAY_MS) + + return () => window.clearTimeout(id) + } + }, [phase, previewing]) + + // Boot failed — BootFailureOverlay owns the screen; don't linger behind it. + if (boot.error && !previewing) { + return null + } + + // Real connect: once the fade finishes, get out of the way for good. + if (phase === 'gone' && !previewing) { + return null + } + + // Never showed (e.g. gateway already up on a warm reload) — stay out. + if (!previewing && !connecting && !shownRef.current) { + return null + } + + const leaving = phase !== 'live' + const overlayHidden = phase === 'overlay-out' || phase === 'gone' + + return ( + <div + className={cn( + 'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out', + overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100' + )} + > + <style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style> + <span + className={cn( + 'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out', + leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100' + )} + > + {PREFIX} + {tail} + <span + aria-hidden="true" + className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]" + style={{ animation: 'gco-cursor 1s step-end infinite' }} + /> + </span> + </div> + ) +} -
apps/desktop/src/hermes.ts@@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient { } } -export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> { +export async function listSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude' +): Promise<PaginatedSessions> { const result = await window.hermesDesktop.api<PaginatedSessions>({ - path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}` }) return { @@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat } } +export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { archived } + }) +} + export function searchSessions(query: string): Promise<SessionSearchResponse> { return window.hermesDesktop.api<SessionSearchResponse>({ path: `/api/sessions/search?q=${encodeURIComponent(query)}` -
apps/desktop/src/lib/icons.ts@@ -2,6 +2,8 @@ import { IconActivity as Activity, IconAlertCircle as AlertCircle, IconAlertTriangle as AlertTriangle, + IconArchive as Archive, + IconArchiveOff as ArchiveOff, IconArrowUp as ArrowUp, IconArrowUpRight as ArrowUpRight, IconAt as AtSign, @@ -98,6 +100,8 @@ export { Activity, AlertCircle, AlertTriangle, + Archive, + ArchiveOff, ArrowUp, ArrowUpRight, AtSign, -
apps/desktop/src/lib/media.ts@@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string { return /^(?:https?|file):/i.test(path) ? path : `file://${path}` } +// Custom Electron scheme (registered in electron/main.cjs) that streams a local +// file with Range support. Used for audio/video so playback bypasses the data +// URL size cap and supports seeking. `path` may be a plain path or `file://…`. +export function mediaStreamUrl(path: string): string { + return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}` +} + export function mediaPathFromMarkdownHref(href?: string): string | null { if (!href?.startsWith('#media:')) { return null -
apps/desktop/src/types/hermes.ts@@ -240,6 +240,7 @@ export interface SessionCreateResponse { } export interface SessionInfo { + archived?: boolean cwd?: null | string ended_at: null | number id: string -
hermes_cli/web_server.py@@ -1131,22 +1131,51 @@ async def get_action_status(name: str, lines: int = 200): @app.get("/api/sessions") -async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0): +async def get_sessions( + limit: int = 20, + offset: int = 0, + min_messages: int = 0, + archived: str = "exclude", +): + """List sessions. + + ``archived`` controls how soft-archived sessions are treated: + ``exclude`` (default) hides them, ``only`` returns just the archived ones + (used by the desktop "Archived sessions" settings panel), and ``include`` + returns both. + """ + if archived not in ("exclude", "only", "include"): + raise HTTPException( + status_code=400, + detail="archived must be one of: exclude, only, include", + ) try: from hermes_state import SessionDB db = SessionDB() try: min_message_count = max(0, min_messages) + archived_only = archived == "only" + include_archived = archived == "include" sessions = db.list_sessions_rich( - limit=limit, offset=offset, min_message_count=min_message_count + limit=limit, + offset=offset, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + ) + total = db.session_count( + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, ) - total = db.session_count(min_message_count=min_message_count) now = time.time() for s in sessions: s["is_active"] = ( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) + # SQLite stores the flag as 0/1; expose a real JSON boolean. + s["archived"] = bool(s.get("archived")) return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() @@ -3707,25 +3736,39 @@ async def delete_session_endpoint(session_id: str): class SessionRename(BaseModel): title: Optional[str] = None + archived: Optional[bool] = None @app.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): - """Rename a session (or clear its title when ``title`` is empty/null).""" + """Update a session: rename (or clear its title) and/or archive it. + + ``title`` renames (empty/null clears the title); ``archived`` soft-hides or + restores the session. Either field may be omitted. + """ from hermes_state import SessionDB db = SessionDB() try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") - try: - updated = db.set_session_title(sid, body.title or "") - except ValueError as e: - # Title too long, invalid characters, or already in use. - raise HTTPException(status_code=400, detail=str(e)) - if not updated: - raise HTTPException(status_code=404, detail="Session not found") - return {"ok": True, "title": db.get_session_title(sid) or ""} + if body.title is None and body.archived is None: + raise HTTPException( + status_code=400, + detail="Nothing to update; provide 'title' and/or 'archived'.", + ) + if body.title is not None: + try: + db.set_session_title(sid, body.title or "") + except ValueError as e: + # Title too long, invalid characters, or already in use. + raise HTTPException(status_code=400, detail=str(e)) + if body.archived is not None: + db.set_session_archived(sid, body.archived) + result = {"ok": True, "title": db.get_session_title(sid) or ""} + if body.archived is not None: + result["archived"] = bool(body.archived) + return result finally: db.close() -
hermes_state.py@@ -264,6 +264,7 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: handoff_platform TEXT, handoff_error TEXT, rewind_count INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); @@ -1430,6 +1431,22 @@ def get_session_title(self, session_id: str) -> Optional[str]: row = cursor.fetchone() return row["title"] if row else None + def set_session_archived(self, session_id: str, archived: bool) -> bool: + """Archive or unarchive a session. + + Archived sessions are hidden from the default session list but keep all + their messages — this is a soft hide, not a delete. Returns True when a + row was updated. + """ + def _do(conn): + cursor = conn.execute( + "UPDATE sessions SET archived = ? WHERE id = ?", + (1 if archived else 0, session_id), + ) + return cursor.rowcount + rowcount = self._execute_write(_do) + return rowcount > 0 + def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Look up a session by exact title. Returns session dict or None.""" with self._lock: @@ -1549,6 +1566,8 @@ def list_sessions_rich( min_message_count: int = 0, project_compression_tips: bool = True, order_by_last_active: bool = False, + include_archived: bool = False, + archived_only: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -1604,6 +1623,10 @@ def list_sessions_rich( if min_message_count > 0: where_clauses.append("s.message_count >= ?") params.append(min_message_count) + if archived_only: + where_clauses.append("s.archived = 1") + elif not include_archived: + where_clauses.append("s.archived = 0") where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" if order_by_last_active: @@ -3027,7 +3050,13 @@ def search_sessions( # Utility # ========================================================================= - def session_count(self, source: str = None, min_message_count: int = 0) -> int: + def session_count( + self, + source: str = None, + min_message_count: int = 0, + include_archived: bool = False, + archived_only: bool = False, + ) -> int: """Count sessions, optionally filtered by source.""" where_clauses = [] params = [] @@ -3038,6 +3067,10 @@ def session_count(self, source: str = None, min_message_count: int = 0) -> int: if min_message_count > 0: where_clauses.append("message_count >= ?") params.append(min_message_count) + if archived_only: + where_clauses.append("archived = 1") + elif not include_archived: + where_clauses.append("archived = 0") where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" -
tests/hermes_cli/test_web_server.py@@ -187,11 +187,11 @@ class _FakeDB: def __init__(self, *args, **kwargs): pass - def list_sessions_rich(self, limit, offset, min_message_count=0): + def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs): captured["list"] = min_message_count return [] - def session_count(self, min_message_count=0): + def session_count(self, min_message_count=0, **kwargs): captured["count"] = min_message_count return 0 @@ -250,6 +250,76 @@ def test_rename_session_not_found(self): resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"}) assert resp.status_code == 404 + def test_archive_session_via_patch(self): + """PATCH archived=true soft-hides a session; archived=false restores it.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="arch-me", source="cli") + db.append_message(session_id="arch-me", role="user", content="hi") + finally: + db.close() + + resp = self.client.patch("/api/sessions/arch-me", json={"archived": True}) + assert resp.status_code == 200 + assert resp.json()["archived"] is True + + # Hidden from the default list, surfaced by archived=only. + listed = self.client.get("/api/sessions").json() + assert all(s["id"] != "arch-me" for s in listed["sessions"]) + only = self.client.get("/api/sessions?archived=only").json() + assert any(s["id"] == "arch-me" for s in only["sessions"]) + + resp = self.client.patch("/api/sessions/arch-me", json={"archived": False}) + assert resp.status_code == 200 + restored = self.client.get("/api/sessions").json() + assert any(s["id"] == "arch-me" for s in restored["sessions"]) + + def test_patch_session_without_fields_is_400(self): + """An existing session + empty body is a bad request, not a 404.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="no-fields", source="cli") + finally: + db.close() + + resp = self.client.patch("/api/sessions/no-fields", json={}) + assert resp.status_code == 400 + + def test_get_sessions_rejects_unknown_archived_value(self): + resp = self.client.get("/api/sessions?archived=bogus") + assert resp.status_code == 400 + + def test_get_sessions_archived_is_boolean(self): + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="bool-arch", source="cli") + db.append_message(session_id="bool-arch", role="user", content="hi") + finally: + db.close() + + row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch") + assert row["archived"] is False + + def test_rename_response_omits_archived_when_not_set(self): + """Title-only PATCH keeps its legacy {ok, title} response shape.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="title-only", source="cli") + finally: + db.close() + + resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"}) + assert resp.status_code == 200 + assert "archived" not in resp.json() + def test_audio_transcription_endpoint(self, monkeypatch): import tools.transcription_tools as transcription_tools -
tests/test_hermes_state.py@@ -3509,3 +3509,43 @@ def execute(self, sql, params=()): assert any("journal_mode=WAL" in sql for sql in conn.executed), ( "set-pragma must fire when probe returns 'delete'" ) + + +class TestSessionArchive: + """Soft-archiving hides a session from default listings without deleting it.""" + + def _seed(self, db, sid, *, archived=False): + db.create_session(session_id=sid, source="cli") + db.append_message(session_id=sid, role="user", content=f"hello from {sid}") + if archived: + db.set_session_archived(sid, True) + + def test_set_session_archived_roundtrip(self, db): + self._seed(db, "s1") + assert db.set_session_archived("s1", True) is True + assert db.get_session("s1")["archived"] == 1 + assert db.set_session_archived("s1", False) is True + assert db.get_session("s1")["archived"] == 0 + + def test_set_session_archived_missing_row(self, db): + assert db.set_session_archived("nope", True) is False + + def test_archived_excluded_by_default(self, db): + self._seed(db, "live") + self._seed(db, "hidden", archived=True) + + ids = [s["id"] for s in db.list_sessions_rich()] + assert ids == ["live"] + assert db.session_count() == 1 + + def test_archived_only_and_include(self, db): + self._seed(db, "live") + self._seed(db, "hidden", archived=True) + + only = [s["id"] for s in db.list_sessions_rich(archived_only=True)] + assert only == ["hidden"] + assert db.session_count(archived_only=True) == 1 + + both = {s["id"] for s in db.list_sessions_rich(include_archived=True)} + assert both == {"live", "hidden"} + assert db.session_count(include_archived=True) == 2 -
tests/test_tui_gateway_server.py@@ -884,6 +884,73 @@ def set_session_title(self, _key, _title): server._sessions.pop("sid", None) +def test_notification_event_routing_by_session_key(monkeypatch): + """Background-process events surface only in the session that owns them.""" + mine = _session(session_key="mine") + other = _session(session_key="other") + monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other}) + + # My own event → handle it. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False + # Global/system event with no owner → handle it. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False + assert server._notification_event_belongs_elsewhere(mine, {}) is False + # Owned by another *live* session → defer to that session's poller. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True + # Owner is gone (not in _sessions) → handle as fallback so it isn't lost. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False + + +def test_session_create_does_not_persist_empty_row(monkeypatch): + """session.create must NOT eagerly write a DB row. + + Every TUI/desktop launch opens a session here just to paint the composer; + eagerly creating a row left an empty "Untitled" session behind for every + launch the user never typed into. The row is created lazily on first prompt. + """ + created = [] + + class _FakeDB: + def create_session(self, *args, **kwargs): + created.append((args, kwargs)) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) + monkeypatch.setattr( + server.threading, + "Timer", + lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None), + ) + + resp = server.handle_request( + {"id": "1", "method": "session.create", "params": {"cols": 80}} + ) + sid = resp["result"]["session_id"] + try: + assert resp["result"]["stored_session_id"] + assert created == [], "session.create should not persist an empty DB row" + finally: + server._sessions.pop(sid, None) + + +def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path): + """First prompt persists the row (INSERT OR IGNORE) capturing cwd up front.""" + created = [] + + class _FakeDB: + def create_session(self, key, source=None, model=None, cwd=None): + created.append({"key": key, "source": source, "model": model, "cwd": cwd}) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)}) + + assert created == [ + {"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)} + ] + + def test_session_title_clears_pending_after_persist(monkeypatch): class _FakeDB: def __init__(self): -
tools/process_registry.py@@ -880,6 +880,7 @@ def _move_to_finished(self, session: ProcessSession): self.completion_queue.put({ "type": "completion", "session_id": session.id, + "session_key": session.session_key, "command": session.command, "exit_code": session.exit_code, "output": output_tail, -
tui_gateway/server.py@@ -702,6 +702,32 @@ def _register_session_cwd(session: dict | None) -> None: pass +def _ensure_session_db_row(session: dict) -> None: + """Idempotently persist the session's DB row on first real activity. + + Called from prompt.submit so a row only exists once the user actually sends + a message — abandoned drafts never leave an empty "Untitled" session behind. + Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own + lazy create) are no-ops. Captures cwd up front so workspace grouping works + without waiting for a separate cwd update. + """ + key = session.get("session_key") + if not key: + return + db = _get_db() + if db is None: + return + try: + db.create_session( + key, + source="tui", + model=_resolve_model(), + cwd=_session_cwd(session), + ) + except Exception: + logger.debug("failed to persist desktop session row", exc_info=True) + + def _set_session_cwd(session: dict, cwd: str) -> str: resolved = os.path.abspath(os.path.expanduser(str(cwd))) if not os.path.isdir(resolved): @@ -2750,17 +2776,12 @@ def _(rid, params: dict) -> dict: "transport": current_transport() or _stdio_transport, } _register_session_cwd(_sessions[sid]) - db = _get_db() - if db is not None: - try: - db.create_session( - key, - source="tui", - model=_resolve_model(), - cwd=_sessions[sid]["cwd"], - ) - except Exception: - logger.debug("failed to pre-create desktop session row", exc_info=True) + # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop + # launch (and every "New agent" / draft) opens a session here just to paint + # the composer, so eagerly creating a row left an "Untitled" empty session + # behind for every launch the user never typed into. The row is now created + # lazily on the first prompt (see _ensure_session_db_row + prompt.submit), + # and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too. # Return the lightweight session immediately so Ink can paint the composer # + skeleton panel, then build the real AIAgent just after this response is @@ -3841,6 +3862,8 @@ def _(rid, params: dict) -> dict: session["last_active"] = time.time() _start_inflight_turn(session, text) + # Persist the DB row lazily, now that the user has actually sent a message. + _ensure_session_db_row(session) _start_agent_build(sid, session) def run_after_agent_ready() -> None: @@ -3865,6 +3888,35 @@ def run_after_agent_ready() -> None: return _ok(rid, {"status": "streaming"}) +def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: + """True if ``evt`` is owned by a *different* live session. + + Background-process events carry the ``session_key`` of the session that + started the process. Since all desktop sessions share one process-wide + completion queue, each poller must skip events it doesn't own so a + background job's completion surfaces in the session that launched it — not + whichever poller happened to dequeue first. Orphaned events (owner gone) + and global/system events (empty ``session_key``) return False so the + current poller still handles them rather than losing them. + """ + evt_key = str(evt.get("session_key") or "") + if not evt_key: + return False + if evt_key == str(session.get("session_key") or ""): + return False + try: + snapshot = list(_sessions.values()) + except Exception: + # If we can't safely enumerate live sessions, fail open so we don't + # crash the poller thread or drop the event. + return False + + return any( + s is not session and str(s.get("session_key") or "") == evt_key + for s in snapshot + ) + + def _notification_poller_loop( stop_event: threading.Event, sid: str, session: dict ) -> None: @@ -3887,6 +3939,16 @@ def _notification_poller_loop( except Exception: continue + # Multiple desktop sessions share this one process-wide queue. Only + # consume events that belong to *this* session — otherwise a background + # process started in session A would surface its completion in whichever + # session's poller happened to wake first (Ben's "reported in a + # different session" bug). Leave foreign events for their owner. + if _notification_event_belongs_elsewhere(session, evt): + process_registry.completion_queue.put(evt) + time.sleep(0.1) + continue + _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue @@ -3917,12 +3979,17 @@ def _notification_poller_loop( session["running"] = False # Drain any remaining events after stop signal (process all pending - # before exiting so nothing is lost on shutdown). + # before exiting so nothing is lost on shutdown). Events owned by other + # live sessions are set aside and re-queued so their poller still sees them. + deferred: list = [] while not process_registry.completion_queue.empty(): try: evt = process_registry.completion_queue.get_nowait() except Exception: break + if _notification_event_belongs_elsewhere(session, evt): + deferred.append(evt) + continue _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue @@ -3951,6 +4018,10 @@ def _notification_poller_loop( with session["history_lock"]: session["running"] = False + # Hand any other sessions' events back to the shared queue. + for evt in deferred: + process_registry.completion_queue.put(evt) + def _start_notification_poller(sid: str, session: dict) -> threading.Event: """Start the background notification poller for a TUI session."""
-
-
#60863 fix(delegation): route async results to their origin session across compression (#55578)
files:
gateway/session_context.py,tests/test_tui_gateway_server.py,tests/tools/test_async_delegation.py,tests/tui_gateway/test_delegation_session_lifecycle.py,tools/async_delegation.py,tools/delegate_tool.py,tui_gateway/server.pyShow the diff
-
gateway/session_context.py@@ -79,6 +79,13 @@ def session_context_engaged() -> bool: _SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET) _SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET) _SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET) +# In-process UI session/window id for multi-session desktop/TUI hosts. This is +# intentionally separate from HERMES_SESSION_ID: the latter is the durable +# conversation/session-db id, while the UI id is the live frontend tab/window +# that commissioned a detached completion. Background completions use it as a +# precise return address so a stale/rotated durable session key cannot be +# consumed by whichever desktop poller wakes first. +_SESSION_UI_SESSION_ID: ContextVar = ContextVar("HERMES_UI_SESSION_ID", default=_UNSET) # ID of the message that triggered the current turn. Used as a reply anchor # so background-process notifications stay inside the originating Telegram # private-chat topic (those lanes route only with thread id + reply anchor). @@ -123,6 +130,7 @@ def session_context_engaged() -> bool: "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME, "HERMES_SESSION_KEY": _SESSION_KEY, "HERMES_SESSION_ID": _SESSION_ID, + "HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID, "HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID, "HERMES_SESSION_PROFILE": _SESSION_PROFILE, "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, @@ -160,6 +168,7 @@ def set_session_vars( profile: str = "", cwd: str = "", async_delivery: bool = True, + ui_session_id: str = "", ) -> list: """Set all session context variables and return reset tokens. @@ -191,6 +200,7 @@ def set_session_vars( _SESSION_USER_NAME.set(user_name), _SESSION_KEY.set(session_key), _SESSION_ID.set(session_id), + _SESSION_UI_SESSION_ID.set(ui_session_id), _SESSION_MESSAGE_ID.set(message_id), _SESSION_PROFILE.set(profile), _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)), @@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None: _SESSION_USER_NAME, _SESSION_KEY, _SESSION_ID, + _SESSION_UI_SESSION_ID, _SESSION_MESSAGE_ID, _SESSION_PROFILE, ): -
tests/test_tui_gateway_server.py@@ -2112,14 +2112,85 @@ def test_notification_event_routing_by_session_key(monkeypatch): monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other}) # My own event → handle it. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "mine"}) is False # Global/system event with no owner → handle it. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False - assert server._notification_event_belongs_elsewhere(mine, {}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": ""}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {}) is False # Owned by another *live* session → defer to that session's poller. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "other"}) is True # Owner is gone (not in _sessions) → handle as fallback so it isn't lost. - assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False + assert server._notification_event_belongs_elsewhere("a", mine, {"session_key": "ghost"}) is False + + +def test_async_delegation_event_prefers_origin_ui_session(monkeypatch): + """Detached subagent completions return to the commissioning TUI tab. + + Regression: when the durable session key was stale/orphaned, whichever + desktop poller woke first could consume the async result and inject it into + an unrelated session. + """ + mine = _session(session_key="current-key") + other = _session(session_key="unrelated-key") + monkeypatch.setattr(server, "_sessions", {"origin-sid": mine, "other-sid": other}) + monkeypatch.setattr(server, "_get_db", lambda: None) + evt = { + "type": "async_delegation", + "session_key": "stale-or-rotated-key", + "origin_ui_session_id": "origin-sid", + } + + assert server._notification_event_belongs_elsewhere("other-sid", other, evt) is True + assert server._notification_event_belongs_elsewhere("origin-sid", mine, evt) is False + + +def test_notification_event_follows_compression_continuation(monkeypatch): + """Events keyed to a compressed parent route to the live continuation.""" + old_parent = _session(session_key="old-parent") + live_tip = _session(session_key="new-tip") + monkeypatch.setattr(server, "_sessions", {"old-sid": old_parent, "tip-sid": live_tip}) + + class _DB: + def resolve_resume_session_id(self, session_id): + return "new-tip" if session_id == "old-parent" else session_id + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + evt = {"type": "async_delegation", "session_key": "old-parent"} + + assert server._notification_event_belongs_elsewhere("old-sid", old_parent, evt) is True + assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False + # A third session must leave it alone for the continuation's poller. + third = _session(session_key="third") + monkeypatch.setattr( + server, + "_sessions", + {"old-sid": old_parent, "tip-sid": live_tip, "third-sid": third}, + ) + assert server._notification_event_belongs_elsewhere("third-sid", third, evt) is True + + +def test_finalized_origin_ui_session_falls_back_to_live_continuation(monkeypatch): + """A closed origin tab must not steal its resumed continuation's result.""" + finalized_origin = _session(session_key="old-parent", _finalized=True) + live_tip = _session(session_key="new-tip") + monkeypatch.setattr( + server, + "_sessions", + {"origin-sid": finalized_origin, "tip-sid": live_tip}, + ) + + class _DB: + def resolve_resume_session_id(self, session_id): + return "new-tip" if session_id == "old-parent" else session_id + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + evt = { + "type": "async_delegation", + "session_key": "old-parent", + "origin_ui_session_id": "origin-sid", + } + + assert server._notification_event_belongs_elsewhere("origin-sid", finalized_origin, evt) is True + assert server._notification_event_belongs_elsewhere("tip-sid", live_tip, evt) is False def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch): -
tests/tools/test_async_delegation.py@@ -289,6 +289,68 @@ def slow_child(task_index, goal, child=None, parent_agent=None, **kw): assert "the real task" in text +def test_delegate_task_background_uses_live_tui_agent_session_id(monkeypatch): + """TUI async delegation must route to the live/compressed agent id. + + Regression: delegate_task captured the stale approval/session context key + after compression rotated parent_agent.session_id. The resulting completion + was orphaned and could be consumed by an unrelated desktop session poller. + """ + import json + from unittest.mock import MagicMock + import tools.delegate_tool as dt + from gateway.session_context import clear_session_vars, set_session_vars + from tools.approval import reset_current_session_key, set_current_session_key + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "post-compress-tip" + parent._interrupt_requested = False + parent._active_children = [] + parent._active_children_lock = None + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + + creds = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + } + monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds) + monkeypatch.setattr( + dt, + "_run_single_child", + lambda *a, **k: { + "task_index": 0, + "status": "completed", + "summary": "done", + "api_calls": 1, + "duration_seconds": 0.1, + "model": "m", + "exit_reason": "completed", + }, + ) + + approval_token = [redacted:secret-assignment]"pre-compress-parent") + session_tokens = [redacted:secret-assignment] + source="tui", + session_key="pre-compress-parent", + ui_session_id="origin-tab", + ) + try: + out = dt.delegate_task(goal="bg task", background=True, parent_agent=parent) + assert json.loads(out)["status"] == "dispatched" + evt = _drain_one() + finally: + reset_current_session_key(approval_token) + clear_session_vars(session_tokens) + + assert evt is not None + assert evt["type"] == "async_delegation" + assert evt["session_key"] == "post-compress-tip" + assert evt["origin_ui_session_id"] == "origin-tab" + + def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch): """A multi-item batch with background=True dispatches the WHOLE fan-out as ONE background unit (one handle, one async slot). The children run in -
tests/tui_gateway/test_delegation_session_lifecycle.py@@ -0,0 +1,158 @@ +"""Fail-closed ownership + session-scoped delegation lifecycle (#55578). + +Covers the two hardening rules layered on top of the origin-routing salvage: + +1. ``_session_owns_notification_event`` — positive-proof ownership. An + async-delegation completion may only be injected into a session that + PROVABLY commissioned it (origin UI id, or session-key/lineage match). + Orphans are never adopted by a foreign chat. + +2. ``interrupt_for_session`` — a session's in-flight async delegations end + with the session. ``_finalize_session`` interrupts delegations owned by + the closing session (by origin UI id always; by durable key only when the + TUI owns the lifecycle). +""" + +import threading +from unittest.mock import MagicMock, patch + +import pytest + +import tools.async_delegation as ad +from tui_gateway.server import ( + _finalize_session, + _session_owns_notification_event, +) + + [redacted:email](autouse=True) +def _reset_async_delegation(): + ad._reset_for_tests() + yield + ad._reset_for_tests() + + +class TestSessionOwnsNotificationEvent: + def _session(self, key="sess_key_1"): + return {"session_key": key, "_finalized": False} + + def test_origin_ui_match_owns(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "other"} + assert _session_owns_notification_event("tab1", self._session(), evt) is True + + def test_session_key_match_owns(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "sess_key_1"} + assert _session_owns_notification_event("tabX", self._session("sess_key_1"), evt) is True + + def test_orphan_is_not_owned(self): + """No origin match, no key match, owner gone → NOT ours (fail closed).""" + evt = {"type": "async_delegation", "origin_ui_session_id": "dead_tab", "session_key": "gone_key"} + assert _session_owns_notification_event("tab1", self._session(), evt) is False + + def test_empty_key_and_origin_not_owned(self): + """A delegation event with no return address at all is never adopted.""" + evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": ""} + assert _session_owns_notification_event("tab1", self._session(), evt) is False + + def test_finalized_session_owns_nothing(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "sess_key_1"} + sess = self._session() + sess["_finalized"] = True + assert _session_owns_notification_event("tab1", sess, evt) is False + + def test_compression_chain_resolution_owns(self): + evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "parent_key"} + db = MagicMock() + db.resolve_resume_session_id.return_value = "child_key" + with patch("tui_gateway.server._get_db", return_value=db): + assert _session_owns_notification_event("tabX", self._session("child_key"), evt) is True + + +class TestInterruptForSession: + def _seed_record(self, delegation_id, session_key="", origin_ui_session_id="", status="running"): + fn = MagicMock() + with ad._records_lock: + ad._records[delegation_id] = { + "delegation_id": delegation_id, + "status": status, + "session_key": session_key, + "origin_ui_session_id": origin_ui_session_id, + "interrupt_fn": fn, + } + return fn + + def test_interrupts_only_matching_session(self): + mine = self._seed_record("d1", session_key="sess_A") + other = self._seed_record("d2", session_key="sess_B") + n = ad.interrupt_for_session(session_key="sess_A") + assert n == 1 + mine.assert_called_once() + other.assert_not_called() + + def test_matches_by_origin_ui_session_id(self): + mine = self._seed_record("d1", origin_ui_session_id="tab1") + other = self._seed_record("d2", origin_ui_session_id="tab2") + n = ad.interrupt_for_session(origin_ui_session_id="tab1") + assert n == 1 + mine.assert_called_once() + other.assert_not_called() + + def test_no_selector_is_noop(self): + fn = self._seed_record("d1", session_key="sess_A") + assert ad.interrupt_for_session() == 0 + fn.assert_not_called() + + def test_completed_records_untouched(self): + fn = self._seed_record("d1", session_key="sess_A", status="completed") + assert ad.interrupt_for_session(session_key="sess_A") == 0 + fn.assert_not_called() + + +class TestFinalizeInterruptsOwnDelegations: + def _make_session(self, session_key="sess_A", sid="tab1"): + agent = MagicMock() + agent.session_id = session_key + agent._session_messages = None + agent.model = "m" + agent.platform = "tui" + return { + "agent": agent, + "history": [{"role": "user", "content": "x"}], + "history_lock": threading.Lock(), + "session_key": session_key, + "_finalized": False, + "_sid": sid, + } + + @patch("tui_gateway.server._get_db") + def test_finalize_interrupts_sessions_delegations(self, mock_get_db): + mock_db = MagicMock() + mock_db.get_session.return_value = {"source": "tui"} + mock_get_db.return_value = mock_db + + with patch("tools.async_delegation.interrupt_for_session") as mock_int: + _finalize_session(self._make_session(), end_reason="tui_close") + + mock_int.assert_called_once() + kwargs = mock_int.call_args.kwargs + assert kwargs["session_key"] == "sess_A" + assert kwargs["origin_ui_session_id"] == "tab1" + + @patch("tui_gateway.server._get_db") + def test_viewer_of_gateway_session_only_interrupts_by_origin(self, mock_get_db): + """Closing a TUI viewer tab on a live gateway session must not kill + the gateway's own background work — key-based interrupt is skipped, + origin-id interrupt (this tab's own dispatches) still applies.""" + mock_db = MagicMock() + mock_db.get_session.return_value = {"source": "telegram"} + mock_get_db.return_value = mock_db + + with patch("tools.async_delegation.interrupt_for_session") as mock_int: + _finalize_session( + self._make_session(session_key="agent:main:telegram:dm:123", sid="tab9"), + end_reason="ws_orphan_reap", + ) + + kwargs = mock_int.call_args.kwargs + assert kwargs["session_key"] == "" + assert kwargs["origin_ui_session_id"] == "tab9" -
tools/async_delegation.py@@ -130,6 +130,7 @@ def dispatch_async_delegation( model: Optional[str], session_key: str, runner: Callable[[], Dict[str, Any]], + origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, ) -> Dict[str, Any]: @@ -172,6 +173,7 @@ def dispatch_async_delegation( "role": role, "model": model, "session_key": session_key, + "origin_ui_session_id": origin_ui_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -282,6 +284,7 @@ def _push_completion_event( # session_key routes the completion back to the originating gateway # session; empty string => CLI (single-session) path. "session_key": record.get("session_key", ""), + "origin_ui_session_id": record.get("origin_ui_session_id", ""), "goal": record.get("goal", ""), "context": record.get("context"), "toolsets": record.get("toolsets"), @@ -317,6 +320,7 @@ def dispatch_async_delegation_batch( model: Optional[str], session_key: str, runner: Callable[[], Dict[str, Any]], + origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, ) -> Dict[str, Any]: @@ -356,6 +360,7 @@ def dispatch_async_delegation_batch( "role": role, "model": model, "session_key": session_key, + "origin_ui_session_id": origin_ui_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -453,6 +458,7 @@ def _finalize_batch( "type": "async_delegation", "delegation_id": delegation_id, "session_key": event_record.get("session_key", ""), + "origin_ui_session_id": event_record.get("origin_ui_session_id", ""), "goal": event_record.get("goal", ""), "goals": event_record.get("goals"), "context": event_record.get("context"), @@ -519,6 +525,54 @@ def interrupt_all(reason: str = "shutdown") -> int: return count +def interrupt_for_session( + session_key: str = "", + origin_ui_session_id: str = "", + reason: str = "session_end", +) -> int: + """Signal running async delegations owned by ONE session to stop. + + A delegation's lifecycle is bound to the session that spawned it: when + that session ends, its in-flight background subagents must end with it — + a completed orphan would otherwise sit on the shared completion queue + with no live owner, either leaking into another chat or burning tokens + with no one listening (#55578). + + Matches on ``origin_ui_session_id`` (the live UI session that + commissioned the work) and/or the durable ``session_key``; either + matching field claims the record. Returns how many were interrupted. + """ + if not session_key and not origin_ui_session_id: + return 0 + count = 0 + with _records_lock: + targets = [ + r for r in _records.values() + if r.get("status") == "running" + and ( + (origin_ui_session_id and str(r.get("origin_ui_session_id") or "") == origin_ui_session_id) + or (session_key and str(r.get("session_key") or "") == session_key) + ) + ] + for r in targets: + fn = r.get("interrupt_fn") + if callable(fn): + try: + fn() + count += 1 + except Exception as exc: + logger.debug( + "interrupt_for_session: %s interrupt failed: %s", + r.get("delegation_id"), exc, + ) + if count: + logger.info( + "Interrupted %d async delegation(s) for ending session (%s)", + count, reason, + ) + return count + + def _reset_for_tests() -> None: """Test-only: clear all state and tear down the executor.""" global _executor, _executor_max_workers -
tools/delegate_tool.py@@ -2796,6 +2796,25 @@ def _execute_and_aggregate() -> dict: return json.dumps(_sync_result, ensure_ascii=False) _session_key = get_current_session_key(default="") + _origin_ui_session_id = "" + try: + from gateway.session_context import get_session_env + + _source = get_session_env("HERMES_SESSION_SOURCE", "") + _origin_ui_session_id = get_session_env("HERMES_UI_SESSION_ID", "") + # In desktop/TUI, the routable session key is the durable + # AIAgent.session_id. Context compression can rotate that id during + # the same turn before the TUI-side session dict is re-anchored; + # if we capture the stale approval/session context key here, the + # async completion becomes an orphan and any desktop poller may + # consume it. Gateway chats are different: their session_key is the + # platform conversation key (agent:main:...), so keep it there. + if _source == "tui": + _agent_session_id = str(getattr(parent_agent, "session_id", "") or "") + if _agent_session_id: + _session_key = _agent_session_id + except Exception: + _origin_ui_session_id = "" _child_agents = [c for (_, _, c) in children] # Detach every child from the parent's interrupt-propagation list — the @@ -2836,6 +2855,7 @@ def _batch_interrupt(): role=top_role, model=creds["model"], session_key=_session_key, + origin_ui_session_id=_origin_ui_session_id, runner=_batch_runner, interrupt_fn=_batch_interrupt, max_async_children=_get_max_async_children(), -
tui_gateway/server.py@@ -625,6 +625,7 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No # Use session_id (from agent.session_id) not session_key — after compression, # session_key may be stale (the ended parent) while session_id is the live # continuation. Fix for #20001. + _tui_owns_lifecycle = True if session_id: try: db = _get_db() @@ -638,11 +639,40 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No # repeats on every inbound message. (#60609) row = db.get_session(session_id) source = (row or {}).get("source", "") - if not _is_gateway_owned_source(source): + _tui_owns_lifecycle = not _is_gateway_owned_source(source) + if _tui_owns_lifecycle: db.end_session(session_id, end_reason) except Exception: pass + # A session's in-flight async delegations end WITH the session (#55578): + # once nobody owns the return address, a still-running background subagent + # can only burn tokens and park an orphaned completion on the shared + # queue. Always interrupt delegations commissioned by THIS live UI session + # (its sid); additionally interrupt by durable session_key, but only when + # the TUI owns the lifecycle — closing a viewer tab on a live gateway + # session must not kill the gateway's own background work. + try: + from tools.async_delegation import interrupt_for_session + + _own_sid = str(session.get("_sid") or "") + if not _own_sid: + try: + with _sessions_lock: + for _cand_sid, _cand in _sessions.items(): + if _cand is session: + _own_sid = _cand_sid + break + except Exception: + _own_sid = "" + interrupt_for_session( + session_key=str(session_key or "") if _tui_owns_lifecycle else "", + origin_ui_session_id=_own_sid, + reason=end_reason, + ) + except Exception: + pass + # Close the slash-worker subprocess as part of finalize itself, not just # in the callers. Defense-in-depth: every session-end path goes through # _finalize_session (it's the single ``_finalized``-guarded chokepoint), so @@ -712,6 +742,10 @@ def _close_session_by_id(sid: str, *, end_reason: str = "tui_close") -> bool: session = _sessions.pop(sid, None) if session is None: return False + # The session is already out of _sessions here, so downstream teardown + # (e.g. _finalize_session's per-session async-delegation interrupt) can't + # recover its live id by scanning the dict — stamp it on the record. + session["_sid"] = sid _teardown_session(session, end_reason=end_reason) return True @@ -1946,7 +1980,12 @@ def _cwd_for_session_key(session_key: str) -> str: return "" -def _set_session_context(session_key: str, cwd: str | None = None) -> list: +def _set_session_context( + session_key: str, + cwd: str | None = None, + *, + ui_session_id: str = "", +) -> list: try: from gateway.session_context import set_session_vars @@ -1961,7 +2000,12 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list: if sess.get("session_key") == session_key: source = _session_source(sess) break - return set_session_vars(session_key=session_key, source=source, cwd=resolved) + return set_session_vars( + session_key=session_key, + source=source, + cwd=resolved, + ui_session_id=ui_session_id, + ) except Exception: return [] @@ -8451,22 +8495,76 @@ def run_after_agent_ready() -> None: return _ok(rid, {"status": "streaming"}) -def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: +def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) -> bool: """True if ``evt`` is owned by a *different* live session. - Background-process events carry the ``session_key`` of the session that - started the process. Since all desktop sessions share one process-wide - completion queue, each poller must skip events it doesn't own so a - background job's completion surfaces in the session that launched it — not - whichever poller happened to dequeue first. Orphaned events (owner gone) - and global/system events (empty ``session_key``) return False so the - current poller still handles them rather than losing them. + Background completions carry the ``session_key`` of the session that started + the work. Async delegation completions from the desktop also carry + ``origin_ui_session_id``: the live TUI tab/window that commissioned them. + Since all desktop sessions share one process-wide completion queue, each + poller must skip events it doesn't own so a detached result surfaces in the + launching session, not whichever poller happened to dequeue first. """ + evt_ui_sid = str(evt.get("origin_ui_session_id") or "") + if evt_ui_sid: + if evt_ui_sid == str(sid or "") and not session.get("_finalized"): + return False + try: + with _sessions_lock: + owner_live = evt_ui_sid in _sessions and not _sessions[evt_ui_sid].get("_finalized") + except Exception: + owner_live = False + if owner_live: + return True + # If the exact UI tab is gone, fall through to durable session_key + # routing. That avoids wrong-session delivery while still allowing a + # resumed continuation with the same durable key/lineage to claim it. + evt_key = str(evt.get("session_key") or "") if not evt_key: return False - if evt_key == str(session.get("session_key") or ""): + + current_keys = { + str(session.get("session_key") or ""), + _session_lookup_key(session, fallback=sid), + } + + # Compression can rotate AIAgent.session_id while the detached child is + # still running. Resolve the event's original key to its continuation tip so + # an event captured before or after compression still maps to the same live + # desktop session instead of becoming an orphan that any poller may consume. + resolved_key = evt_key + try: + db = _get_db() + if db is not None: + resolved_key = db.resolve_resume_session_id(evt_key) or evt_key + except Exception: + resolved_key = evt_key + + # If the key has a live continuation, prefer that continuation over the + # compressed parent. Otherwise a stale parent tab could consume the event + # before the real current conversation sees it. + if resolved_key != evt_key: + if resolved_key in current_keys: + return False + try: + with _sessions_lock: + continuation_live = any( + not s.get("_finalized") + and ( + str(s.get("session_key") or "") == resolved_key + or _session_lookup_key(s, fallback="") == resolved_key + ) + for s in _sessions.values() + ) + except Exception: + continuation_live = False + if continuation_live: + return True + + if evt_key in current_keys: return False + try: with _sessions_lock: snapshot = list(_sessions.values()) @@ -8476,11 +8574,50 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: return False return any( - s is not session and str(s.get("session_key") or "") == evt_key + s is not session + and not s.get("_finalized") + and ( + str(s.get("session_key") or "") in {evt_key, resolved_key} + or _session_lookup_key(s, fallback="") in {evt_key, resolved_key} + ) for s in snapshot ) +def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool: + """True iff *this* session PROVABLY owns ``evt``. + + Positive ownership — the mirror of ``_notification_event_belongs_elsewhere`` + minus its orphan-adoption fallback. An event owns-matches when its + ``origin_ui_session_id`` is this live session, or its ``session_key`` + (raw or resolved through the compression chain) matches this session's + key/lineage. Used as a fail-closed gate for async-delegation payloads: + "not provably elsewhere" is NOT good enough to inject a conversation + payload into this chat (#55578). + """ + if session.get("_finalized"): + return False + if str(evt.get("origin_ui_session_id") or "") == str(sid or ""): + return True + evt_key = str(evt.get("session_key") or "") + if not evt_key: + return False + current_keys = { + str(session.get("session_key") or ""), + _session_lookup_key(session, fallback=sid), + } + if evt_key in current_keys: + return True + try: + db = _get_db() + resolved_key = ( + db.resolve_resume_session_id(evt_key) if db is not None else evt_key + ) or evt_key + except Exception: + resolved_key = evt_key + return resolved_key in current_keys + + def _notification_event_dedup_key(evt: dict) -> tuple: """Return the UI-emission identity for a process notification event. @@ -8546,11 +8683,37 @@ def _notification_poller_loop( # process started in session A would surface its completion in whichever # session's poller happened to wake first (Ben's "reported in a # different session" bug). Leave foreign events for their owner. - if _notification_event_belongs_elsewhere(session, evt): + if _notification_event_belongs_elsewhere(sid, session, evt): process_registry.completion_queue.put(evt) time.sleep(0.1) continue + # Fail closed for async-delegation results (#55578): these carry a + # conversation payload, and injecting one into any chat other than the + # one that commissioned it is a hard cross-session leak. The + # belongs-elsewhere check above already re-queued events owned by + # another LIVE session; what reaches here is either ours or an + # orphan whose owner is gone. Orphaned delegation payloads are + # DROPPED, not adopted — the subagent's summary is already persisted + # in the delegation records/output store, so nothing is lost, whereas + # a wrong-chat injection is unrecoverable. Non-delegation events + # (background process completions etc.) keep the historical + # adopt-orphans behavior. + if evt.get("type") == "async_delegation" and not _session_owns_notification_event( + sid, session, evt + ): + logger.warning( + "async-delegation completion %s has no live owner " + "(origin=%r key=%r); dropping from injection instead of " + "delivering to session %s (#55578 fail-closed; result " + "remains in the delegation records)", + evt.get("delegation_id", "?"), + str(evt.get("origin_ui_session_id") or ""), + str(evt.get("session_key") or ""), + sid, + ) + continue + _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue @@ -8604,7 +8767,16 @@ def _notification_poller_loop( evt = process_registry.completion_queue.get_nowait() except Exception: break - if _notification_event_belongs_elsewhere(session, evt): + if _notification_event_belongs_elsewhere(sid, session, evt): + deferred.append(evt) + continue + # Same fail-closed rule as the live loop: an orphaned async-delegation + # payload is never adopted by a foreign session — defer it (a later + # resume of the owner's lineage can still claim it) rather than + # injecting another chat's conversation here (#55578). + if evt.get("type") == "async_delegation" and not _session_owns_notification_event( + sid, session, evt + ): deferred.append(evt) continue _evt_sid = evt.get("session_id", "") @@ -8729,7 +8901,10 @@ def run(): ) approval_token = [redacted:secret-assignment]"session_key"]) - session_tokens = [redacted:secret-assignment]"session_key"]) + session_tokens = [redacted:secret-assignment] + session["session_key"], + ui_session_id=sid, + ) _profile_home_str = session.get("profile_home") if _profile_home_str: home_token = [redacted:secret-assignment]
-
-
#60869 fix(delegation): session-filter the post-turn notification drain (#58684)
files:
cli.py,gateway/run.py,tests/tools/test_process_registry.py,tools/process_registry.py,tui_gateway/server.pyShow the diff
-
cli.py@@ -15082,7 +15082,9 @@ def process_loop(): # and watch pattern matches) while agent is idle. try: from tools.process_registry import process_registry - for _evt, _synth in process_registry.drain_notifications(): + from tools.approval import get_current_session_key + _drain_sk = get_current_session_key(default="") + for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk): self._pending_input.put(_synth) except Exception: pass -
gateway/run.py@@ -15143,6 +15143,13 @@ def _build_process_event_source(self, evt: dict): chat_type = str(evt.get("chat_type") or derived_chat_type or "").strip().lower() chat_id = str(evt.get("chat_id") or derived_chat_id or "").strip() if not platform_name or not chat_type or not chat_id: + logger.warning( + "Synthetic event source unresolvable: " + "session_key=%r platform=%r chat_type=%r chat_id=%r " + "evt_type=%s", + session_key, platform_name, chat_type, chat_id, + evt.get("type", "?"), + ) return None try: -
tests/tools/test_process_registry.py@@ -1346,6 +1346,181 @@ def test_drain_notifications_empty_queue(): assert results == [] +def test_drain_notifications_filters_async_delegation_by_session_key(): + """Async-delegation events should only be consumed by the matching session's drain. + + Regression test for issue #58684: background delegation results delivered + to the wrong session when the user switches sessions while a subagent runs. + """ + from tools.process_registry import process_registry + + # Clear the queue first + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + # Put events for different sessions + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_session_a", + "session_key": "telegram:dm:111:user_a", + "goal": "task A", + "status": "completed", + "summary": "done A", + "api_calls": 1, + "duration_seconds": 0.5, + }) + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_session_b", + "session_key": "telegram:dm:222:user_b", + "goal": "task B", + "status": "completed", + "summary": "done B", + "api_calls": 1, + "duration_seconds": 0.3, + }) + + # Drain for session A — should only get deleg_session_a + results_a = process_registry.drain_notifications(session_key="telegram:dm:111:user_a") + assert len(results_a) == 1, ( + f"Expected 1 event for session A, got {len(results_a)}" + ) + assert results_a[0][0]["delegation_id"] == "deleg_session_a" + assert "done A" in results_a[0][1] + + # Session B's event should have been re-queued — drain for session B + results_b = process_registry.drain_notifications(session_key="telegram:dm:222:user_b") + assert len(results_b) == 1, ( + f"Expected 1 event for session B, got {len(results_b)}" + ) + assert results_b[0][0]["delegation_id"] == "deleg_session_b" + assert "done B" in results_b[0][1] + + # No more events should remain + assert process_registry.completion_queue.empty() + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_drain_notifications_no_filter_passes_all_async_delegation(): + """Without a session_key filter, all async-delegation events are consumed. + + This ensures backward compatibility — the default (session_key="") permits + all events, matching pre-fix behavior. + """ + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_1", + "session_key": "telegram:dm:111:user_a", + "goal": "task 1", + "status": "completed", + "summary": "done 1", + "api_calls": 1, + "duration_seconds": 0.5, + }) + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_2", + "session_key": "telegram:dm:222:user_b", + "goal": "task 2", + "status": "completed", + "summary": "done 2", + "api_calls": 1, + "duration_seconds": 0.3, + }) + + # No filter — both should be consumed + results = process_registry.drain_notifications() + assert len(results) == 2, ( + f"Expected 2 events without filter, got {len(results)}" + ) + ids = {r[0]["delegation_id"] for r in results} + assert ids == {"deleg_1", "deleg_2"} + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_drain_notifications_owns_event_callback_beats_key_equality(): + """The positive-proof ownership callback consumes ONLY approved events — + including across a compression rotation where bare key equality would + wrongly re-queue the session's own pre-compression dispatch (#55578).""" + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + # Pre-compression dispatch: event carries the OLD key. + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_precompress", + "session_key": "old_parent_key", + "goal": "task", "status": "completed", "summary": "mine", + "api_calls": 1, "duration_seconds": 0.1, + }) + # Foreign event that plain key equality would also reject. + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_foreign", + "session_key": "someone_else", + "goal": "task", "status": "completed", "summary": "not mine", + "api_calls": 1, "duration_seconds": 0.1, + }) + + # Chain-aware ownership: this session's lineage includes old_parent_key. + lineage = {"old_parent_key", "new_child_key"} + results = process_registry.drain_notifications( + session_key="new_child_key", + owns_event=lambda e: e.get("session_key") in lineage, + ) + assert [r[0]["delegation_id"] for r in results] == ["deleg_precompress"] + + # The foreign event was re-queued, not consumed. + leftover = process_registry.completion_queue.get_nowait() + assert leftover["delegation_id"] == "deleg_foreign" + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_drain_notifications_owns_event_callback_fails_closed(): + """A broken ownership callback must re-queue (never leak) the event.""" + from tools.process_registry import process_registry + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + try: + process_registry.completion_queue.put({ + "type": "async_delegation", + "delegation_id": "deleg_x", + "session_key": "k", + "goal": "task", "status": "completed", "summary": "s", + "api_calls": 1, "duration_seconds": 0.1, + }) + + def broken(_evt): + raise RuntimeError("ownership check exploded") + + results = process_registry.drain_notifications( + session_key="k", owns_event=broken + ) + assert results == [] + assert process_registry.completion_queue.get_nowait()["delegation_id"] == "deleg_x" + finally: + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + # --------------------------------------------------------------------------- # _terminate_host_pid — cross-platform process-tree termination # --------------------------------------------------------------------------- -
tools/process_registry.py@@ -1148,14 +1148,33 @@ def _drain_should_skip(self, session_id: str) -> bool: """ return session_id in self._completion_consumed or session_id in self._poll_observed - def drain_notifications(self) -> "list[tuple[dict, str]]": + def drain_notifications( + self, session_key: str = "", owns_event=None, + ) -> "list[tuple[dict, str]]": """Pop all pending notification events and return formatted pairs. Returns a list of (raw_event, formatted_text) tuples. Skips completion events the agent already consumed via wait/log or observed inline via poll() (see ``_drain_should_skip``). + + Async-delegation events carry a conversation payload, so draining one + into the wrong session is a cross-chat leak (#58684, #55578). Two + filter modes, strongest wins: + + - ``owns_event(evt) -> bool``: positive-proof ownership callback. + When provided, an async-delegation event is consumed ONLY if the + callback returns True; everything else is re-queued for its owner. + The TUI passes its compression-chain-aware ownership check here so + a post-compression session still claims its own pre-compression + dispatches. + - ``session_key``: plain key equality (CLI and other single-session + callers). Non-matching async-delegation events are re-queued. + + With neither set, all events are consumed (legacy single-session + behavior, backward compatible). """ - results = [] + results: "list[tuple[dict, str]]" = [] + requeue: "list[dict]" = [] while not self.completion_queue.empty(): try: evt = self.completion_queue.get_nowait() @@ -1164,9 +1183,28 @@ def drain_notifications(self) -> "list[tuple[dict, str]]": _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and self._drain_should_skip(_evt_sid): continue + # Filter async-delegation events so they are not delivered to the + # wrong session/thread (#58684). Positive-proof callback beats + # bare key equality when the caller can provide one. + if evt.get("type") == "async_delegation": + if owns_event is not None: + try: + owned = bool(owns_event(evt)) + except Exception: + owned = False # fail closed — never leak on a broken check + if not owned: + requeue.append(evt) + continue + elif session_key: + evt_session_key = evt.get("session_key", "") or "" + if evt_session_key != session_key: + requeue.append(evt) + continue text = format_process_notification(evt) if text: results.append((evt, text)) + for evt in requeue: + self.completion_queue.put(evt) return results def get(self, session_id: str) -> Optional[ProcessSession]: -
tui_gateway/server.py@@ -9337,7 +9337,15 @@ def _stream(delta): try: from tools.process_registry import process_registry - for _evt, synth in process_registry.drain_notifications(): + # Positive-proof ownership (compression-chain aware) — the same + # fail-closed gate the poller uses, so the post-turn drain can't + # adopt another session's (or an orphan's) delegation payload, + # while a post-compression session still claims its own + # pre-compression dispatches (#55578). + for _evt, synth in process_registry.drain_notifications( + session_key=session.get("session_key", ""), + owns_event=lambda e: _session_owns_notification_event(sid, session, e), + ): with session["history_lock"]: if session.get("running"): process_registry.completion_queue.put(_evt)
-
-
#60871 fix(delegate): pin gateway async completions to the spawning session (#57498)
files:
gateway/run.py,gateway/slash_commands.py,tests/gateway/test_async_delegation_session_binding.py,tests/tools/test_async_delegation.py,tools/async_delegation.py,tools/delegate_tool.pyShow the diff
-
gateway/run.py@@ -10633,6 +10633,46 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key + pinned_session_id = str( + (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" + ).strip() + if pinned_session_id and pinned_session_id != session_entry.session_id: + # Fail closed (#55578): the spawning session may have ENDED since + # dispatch (user /new-reset, compression rotation whose parent was + # closed). switch_session() re-opens ended sessions, so pinning + # blindly would RESURRECT a conversation the user explicitly + # ended and inject into it — the same illicit-revival class as + # the ws_orphan_reap loop (#60609). A completion whose spawning + # session is dead is dropped from injection; the subagent's + # output remains in the delegation records. + pinned_row = None + try: + if self._session_db is not None: + # AsyncSessionDB already offloads to a thread. + pinned_row = await self._session_db.get_session(pinned_session_id) + except Exception: + pinned_row = None + if pinned_row is None or pinned_row.get("ended_at"): + logger.warning( + "Async-delegation completion pinned to session %s, which is " + "%s — dropping injection instead of resurrecting it " + "(#55578 fail-closed; result remains in the delegation " + "records).", + pinned_session_id, + "unknown" if pinned_row is None else "ended", + ) + return + prior_session_id = session_entry.session_id + switched = self.session_store.switch_session(session_key, pinned_session_id) + if switched is not None: + session_entry = switched + logger.info( + "Pinned async-delegation completion to spawning session %s " + "(was %s) for routing key %s (#57498)", + pinned_session_id, + prior_session_id, + session_key, + ) self._cache_session_source(session_key, source) if await asyncio.to_thread(self._is_telegram_topic_lane, source): try: @@ -15202,12 +15242,17 @@ async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: if not adapter: return try: + metadata = {} + parent_session_id = str(evt.get("parent_session_id") or "").strip() + if parent_session_id: + metadata["gateway_session_id"] = parent_session_id synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, source=source, internal=True, message_id=str(evt.get("message_id") or "").strip() or None, + metadata=metadata, ) logger.info( "Watch pattern notification — injecting for %s chat=%s thread=%s", -
gateway/slash_commands.py@@ -167,6 +167,24 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer if _qe is not None: _qe.pop(session_key, None) + # The old conversation's in-flight async delegations end WITH it + # (#55578): after the reset rotates the session id, their completions + # would have no live owner — a dangling subagent can only burn tokens + # and park an orphaned payload on the shared queue. Interrupt by the + # expiring durable session id (delegations dispatched from gateway + # chats are pinned to it via parent_session_id) and by the routing + # key as a fallback for older records. + try: + from tools.async_delegation import interrupt_for_session + + interrupt_for_session( + session_key=session_key, + parent_session_id=str(getattr(old_entry, "session_id", "") or ""), + reason="session_reset", + ) + except Exception: + pass + try: from tools.env_passthrough import clear_env_passthrough clear_env_passthrough() -
tests/gateway/test_async_delegation_session_binding.py@@ -0,0 +1,130 @@ +"""Gateway-side session binding for async delegations (#57498, #55578). + +Three invariants on the messaging-gateway surface, mirroring the TUI rules: + +1. Completions are pinned to the spawning session (contributor commit). +2. A dead/ended spawning session is never resurrected: the injection is + dropped, fail-closed (never rerouted to the peer's current session). +3. /new interrupts the old conversation's in-flight async delegations. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import tools.async_delegation as ad + + [redacted:email](autouse=True) +def _reset_async_delegation(): + ad._reset_for_tests() + yield + ad._reset_for_tests() + + +def _seed_record(delegation_id, session_key="", parent_session_id="", status="running"): + fn = MagicMock() + with ad._records_lock: + ad._records[delegation_id] = { + "delegation_id": delegation_id, + "status": status, + "session_key": session_key, + "parent_session_id": parent_session_id, + "interrupt_fn": fn, + } + return fn + + +class TestInterruptForSessionByParentId: + def test_parent_session_id_selector(self): + mine = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="sess_old") + other = _seed_record("d2", session_key="agent:main:telegram:dm:2", parent_session_id="sess_other") + n = ad.interrupt_for_session(parent_session_id="sess_old") + assert n == 1 + mine.assert_called_once() + other.assert_not_called() + + def test_reset_interrupts_by_key_and_parent(self): + """A /new reset passes both selectors — either match claims the record.""" + by_key = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="") + by_parent = _seed_record("d2", session_key="", parent_session_id="sess_old") + unrelated = _seed_record("d3", session_key="other", parent_session_id="other") + n = ad.interrupt_for_session( + session_key="agent:main:telegram:dm:1", + parent_session_id="sess_old", + reason="session_reset", + ) + assert n == 2 + by_key.assert_called_once() + by_parent.assert_called_once() + unrelated.assert_not_called() + + +class TestGatewayPinningFailsClosed: + """The gateway injection path must never resurrect an ended session.""" + + def _make_runner(self, pinned_row): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + db = MagicMock() + db.get_session = AsyncMock(return_value=pinned_row) + runner._session_db = db + + entry = MagicMock() + entry.session_key = "agent:main:telegram:dm:1" + entry.session_id = "sess_current" + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = entry + runner.session_store.switch_session.return_value = entry + return runner, entry + + def _run_pinning_prefix(self, runner, pinned_session_id): + """Execute the pinning guard logic exactly as _handle_message does.""" + + async def _go(): + event = MagicMock() + event.metadata = {"gateway_session_id": pinned_session_id} + session_entry = runner.session_store.get_or_create_session(MagicMock()) + pinned = str((getattr(event, "metadata", None) or {}).get("gateway_session_id") or "").strip() + if pinned and pinned != session_entry.session_id: + pinned_row = None + try: + if runner._session_db is not None: + pinned_row = await runner._session_db.get_session(pinned) + except Exception: + pinned_row = None + if pinned_row is None or pinned_row.get("ended_at"): + return "dropped" + switched = runner.session_store.switch_session(session_entry.session_key, pinned) + if switched is not None: + return "pinned" + return "default" + + return asyncio.run(_go()) + + def test_live_spawning_session_pins(self): + runner, _ = self._make_runner({"id": "sess_old", "ended_at": None}) + assert self._run_pinning_prefix(runner, "sess_old") == "pinned" + + def test_ended_spawning_session_drops(self): + runner, _ = self._make_runner({"id": "sess_old", "ended_at": "2026-07-08T00:00:00"}) + assert self._run_pinning_prefix(runner, "sess_old") == "dropped" + runner.session_store.switch_session.assert_not_called() + + def test_unknown_spawning_session_drops(self): + runner, _ = self._make_runner(None) + assert self._run_pinning_prefix(runner, "sess_gone") == "dropped" + runner.session_store.switch_session.assert_not_called() + + +class TestResetHandlerInterruptsDelegations: + def test_reset_command_calls_interrupt_for_session(self): + """The /new handler must sever the old conversation's delegations.""" + import inspect + from gateway import slash_commands + + src = inspect.getsource(slash_commands.GatewaySlashCommandsMixin._handle_reset_command) + assert "interrupt_for_session" in src + assert "session_reset" in src -
tests/tools/test_async_delegation.py@@ -99,6 +99,7 @@ def runner(): res = ad.dispatch_async_delegation( goal="compute X", context="some context", toolsets=["web", "file"], role="leaf", model="test-model", session_key="agent:main:cli:dm:local", + parent_session_id="20260703_parent_sid", runner=runner, max_async_children=3, ) assert res["status"] == "dispatched" @@ -108,6 +109,7 @@ def runner(): assert evt["type"] == "async_delegation" assert evt["summary"] == "the result" assert evt["session_key"] == "agent:main:cli:dm:local" + assert evt["parent_session_id"] == "20260703_parent_sid" assert evt["delegation_id"] == res["delegation_id"] -
tools/async_delegation.py@@ -129,6 +129,7 @@ def dispatch_async_delegation( role: str, model: Optional[str], session_key: str, + parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]], origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, @@ -146,6 +147,11 @@ def dispatch_async_delegation( captured on the parent thread BEFORE dispatch, because the daemon worker thread won't carry the contextvar. Used to route the completion back to the originating session. + parent_session_id + The durable ``state.db`` session id of the parent agent that spawned + the delegation. Carried on the completion event so the gateway can + pin routing to the spawning session instead of recovering the latest + ``ended_at IS NULL`` row for the peer tuple (#57498). runner Zero-arg callable that builds + runs the child and returns the same result dict ``_run_single_child`` produces. Runs on the worker thread. @@ -174,6 +180,7 @@ def dispatch_async_delegation( "model": model, "session_key": session_key, "origin_ui_session_id": origin_ui_session_id, + "parent_session_id": parent_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -285,6 +292,7 @@ def _push_completion_event( # session; empty string => CLI (single-session) path. "session_key": record.get("session_key", ""), "origin_ui_session_id": record.get("origin_ui_session_id", ""), + "parent_session_id": record.get("parent_session_id"), "goal": record.get("goal", ""), "context": record.get("context"), "toolsets": record.get("toolsets"), @@ -319,6 +327,7 @@ def dispatch_async_delegation_batch( role: str, model: Optional[str], session_key: str, + parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]], origin_ui_session_id: str = "", interrupt_fn: Optional[Callable[[], None]] = None, @@ -361,6 +370,7 @@ def dispatch_async_delegation_batch( "model": model, "session_key": session_key, "origin_ui_session_id": origin_ui_session_id, + "parent_session_id": parent_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -459,6 +469,7 @@ def _finalize_batch( "delegation_id": delegation_id, "session_key": event_record.get("session_key", ""), "origin_ui_session_id": event_record.get("origin_ui_session_id", ""), + "parent_session_id": event_record.get("parent_session_id"), "goal": event_record.get("goal", ""), "goals": event_record.get("goals"), "context": event_record.get("context"), @@ -528,6 +539,7 @@ def interrupt_all(reason: str = "shutdown") -> int: def interrupt_for_session( session_key: str = "", origin_ui_session_id: str = "", + parent_session_id: str = "", reason: str = "session_end", ) -> int: """Signal running async delegations owned by ONE session to stop. @@ -538,11 +550,17 @@ def interrupt_for_session( with no live owner, either leaking into another chat or burning tokens with no one listening (#55578). - Matches on ``origin_ui_session_id`` (the live UI session that - commissioned the work) and/or the durable ``session_key``; either - matching field claims the record. Returns how many were interrupted. + Selectors (any matching field claims the record): + - ``origin_ui_session_id``: the live TUI tab/window that commissioned it. + - ``session_key``: the durable routing key captured at dispatch. + - ``parent_session_id``: the spawning agent's durable session-db id — + the right selector for gateway chats, whose ``session_key`` (the + platform conversation key) SURVIVES a ``/new`` reset while the + session id rotates. + + Returns how many were interrupted. """ - if not session_key and not origin_ui_session_id: + if not session_key and not origin_ui_session_id and not parent_session_id: return 0 count = 0 with _records_lock: @@ -552,6 +570,7 @@ def interrupt_for_session( and ( (origin_ui_session_id and str(r.get("origin_ui_session_id") or "") == origin_ui_session_id) or (session_key and str(r.get("session_key") or "") == session_key) + or (parent_session_id and str(r.get("parent_session_id") or "") == parent_session_id) ) ] for r in targets: -
tools/delegate_tool.py@@ -2815,6 +2815,7 @@ def _execute_and_aggregate() -> dict: _session_key = _agent_session_id except Exception: _origin_ui_session_id = "" + _parent_session_id = getattr(parent_agent, "session_id", None) _child_agents = [c for (_, _, c) in children] # Detach every child from the parent's interrupt-propagation list — the @@ -2856,6 +2857,7 @@ def _batch_interrupt(): model=creds["model"], session_key=_session_key, origin_ui_session_id=_origin_ui_session_id, + parent_session_id=_parent_session_id, runner=_batch_runner, interrupt_fn=_batch_interrupt, max_async_children=_get_max_async_children(),
-
-
#64593 fix(cli): stop new sessions adopting dead sessions' delegation completions (#64484)
files:
cli.py,tests/cli/test_cli_async_delegation_delivery.py,tests/tools/test_restored_delegation_ownership.py,tools/async_delegation.py,tools/delegate_tool.py,tools/process_registry.pyShow the diff
-
cli.py@@ -9148,6 +9148,55 @@ def _get_goal_manager(self): + def _owns_process_notification(self, event: dict) -> bool: + """Return whether this CLI session provably owns a delegation event. + + Delegations dispatched before context compression retain the original + session key, so resolve that key to its continuation before comparing. + Missing or foreign keys fail closed and remain queued for their owner. + """ + event_key = str(event.get("session_key") or "") + current_key = str(getattr(self, "session_id", "") or "") + if not event_key or not current_key: + return False + if event_key == current_key: + return True + try: + session_db = getattr(self, "_session_db", None) + resolved_key = ( + session_db.resolve_resume_session_id(event_key) + if session_db is not None + else event_key + ) or event_key + except Exception: + resolved_key = event_key + return str(resolved_key) == current_key + + def _drain_process_notifications(self, consumer: str) -> None: + """Queue background notifications owned by this visible CLI session. + + ``process_registry`` restores durable delegation completions into every + process using the same Hermes profile. Always pass this CLI's stable + session identity when draining so another window cannot claim and mark + delivered a completion that belongs to this one. + """ + from tools.process_registry import process_registry + from tools.async_delegation import ( + claim_event_delivery, + complete_event_delivery, + ) + + session_key = getattr(self, "session_id", "") or "" + for event, synthetic_message in process_registry.drain_notifications( + session_key=session_key, + owns_event=self._owns_process_notification, + ): + claim = claim_event_delivery(event, consumer) + if claim is None: + continue + self._pending_input.put(synthetic_message) + complete_event_delivery(event, claim) + def _drain_interrupt_queue_to_pending_input(self) -> None: """Move stray messages from ``_interrupt_queue`` into ``_pending_input``. @@ -15321,18 +15370,7 @@ def process_loop(): # Check for background process notifications (completions # and watch pattern matches) while agent is idle. try: - from tools.process_registry import process_registry - from tools.approval import get_current_session_key - _drain_sk = get_current_session_key(default="") - for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk): - from tools.async_delegation import ( - claim_event_delivery, complete_event_delivery, - ) - _claim = claim_event_delivery(_evt, "cli-idle") - if _claim is None: - continue - self._pending_input.put(_synth) - complete_event_delivery(_evt, _claim) + self._drain_process_notifications("cli-idle") except Exception: pass continue @@ -15492,16 +15530,7 @@ def _restart_recording(): # Drain process notifications (completions + watch matches) # that arrived while the agent was running. try: - from tools.process_registry import process_registry - for _evt, _synth in process_registry.drain_notifications(): - from tools.async_delegation import ( - claim_event_delivery, complete_event_delivery, - ) - _claim = claim_event_delivery(_evt, "cli-post-turn") - if _claim is None: - continue - self._pending_input.put(_synth) - complete_event_delivery(_evt, _claim) + self._drain_process_notifications("cli-post-turn") except Exception: pass # Non-fatal — don't break the main loop -
tests/cli/test_cli_async_delegation_delivery.py@@ -0,0 +1,76 @@ +"""Regression coverage for CLI async-delegation completion ownership.""" + +import queue + +from cli import HermesCLI + + +def test_cli_completion_drain_uses_visible_session_identity(monkeypatch): + """A CLI window must not claim another window's restored completion.""" + cli = HermesCLI.__new__(HermesCLI) + cli.session_id = "visible-session" + cli._pending_input = queue.Queue() + + event = { + "type": "async_delegation", + "delegation_id": "deleg_visible", + "session_key": "visible-session", + } + calls = [] + + class FakeRegistry: + def drain_notifications(self, *, session_key="", owns_event=None): + calls.append((session_key, owns_event(event))) + return [(event, "completion payload")] + + claimed = [] + completed = [] + + monkeypatch.setattr( + "tools.process_registry.process_registry", + FakeRegistry(), + ) + monkeypatch.setattr( + "tools.async_delegation.claim_event_delivery", + lambda evt, consumer: claimed.append((evt, consumer)) or "claim-token", + ) + monkeypatch.setattr( + "tools.async_delegation.complete_event_delivery", + lambda evt, token: [redacted:secret-assignment] token)), + ) + + cli._drain_process_notifications("cli-idle") + + assert calls == [("visible-session", True)] + assert cli._pending_input.get_nowait() == "completion payload" + assert claimed == [(event, "cli-idle")] + assert completed == [(event, "claim-token")] + + +def test_cli_completion_ownership_rejects_foreign_session(): + cli = HermesCLI.__new__(HermesCLI) + cli.session_id = "visible-session" + cli._session_db = None + + assert not cli._owns_process_notification( + {"type": "async_delegation", "session_key": "foreign-session"} + ) + + +def test_cli_completion_ownership_accepts_compression_lineage(): + cli = HermesCLI.__new__(HermesCLI) + cli.session_id = "visible-session" + + class FakeSessionDB: + def resolve_resume_session_id(self, session_id): + assert session_id == "pre-compression-session" + return "visible-session" + + cli._session_db = FakeSessionDB() + + assert cli._owns_process_notification( + { + "type": "async_delegation", + "session_key": "pre-compression-session", + } + ) -
tests/tools/test_restored_delegation_ownership.py@@ -0,0 +1,149 @@ +"""Regression coverage for #64484 — durable-restored delegation completions +must never be adopted by a session that cannot positively prove ownership. + +Layers under test: +1. ``restore_undelivered_completions`` stamps every restored event with + ``restored=True`` (in-memory only). +2. ``ProcessRegistry.drain_notifications`` with NO filter (legacy + consume-everything CLI path) re-queues restored events instead of + consuming them. +3. Same-process (non-restored) keyless events keep the legacy behavior. +4. An owner with a matching session_key still receives its restored event. +""" + +import json +import queue + +from tools.process_registry import ProcessRegistry + + +def _make_registry(): + reg = ProcessRegistry.__new__(ProcessRegistry) + import threading + + reg._running = {} + reg._finished = {} + reg._lock = threading.Lock() + reg.completion_queue = queue.Queue() + reg._completion_consumed = set() + reg._poll_observed = set() + return reg + + +def _delegation_event(session_key="", restored=False, delegation_id="d1"): + evt = { + "type": "async_delegation", + "delegation_id": delegation_id, + "session_key": session_key, + "origin_ui_session_id": "", + "goal": "secret goal", + "status": "success", + "summary": "SECRET RESULT", + "api_calls": 3, + "duration_seconds": 1.5, + "dispatched_at": 1.0, + "completed_at": 2.0, + } + if restored: + evt["restored"] = True + return evt + + +def test_restore_stamps_restored_flag(tmp_path, monkeypatch): + """Every durable completion re-enqueued at startup carries restored=True.""" + import tools.async_delegation as ad + + monkeypatch.setattr(ad, "_db_path", lambda: tmp_path / "async_delegations.db") + record = { + "delegation_id": "d-old", + "goal": "old goal", + "context": None, + "toolsets": None, + "role": "leaf", + "model": "m", + "session_key": "OLD_SESSION_A", + "origin_ui_session_id": "", + "parent_session_id": "OLD_SESSION_A", + "status": "running", + "dispatched_at": 1.0, + "completed_at": None, + "interrupt_fn": None, + } + ad._persist_dispatch(record) + evt = _delegation_event(session_key="OLD_SESSION_A", delegation_id="d-old") + ad._persist_completion(evt, {"summary": "SECRET RESULT"}) + + q = queue.Queue() + restored = ad.restore_undelivered_completions(q) + assert restored == 1 + got = q.get_nowait() + assert got["restored"] is True + assert got["session_key"] == "OLD_SESSION_A" + + # The stamp is in-memory only — the durable payload is unchanged. + with ad._connect() as conn: + row = conn.execute( + "SELECT event_json FROM async_delegations WHERE delegation_id='d-old'" + ).fetchone() + assert "restored" not in json.loads(row[0]) + + +def test_unfiltered_drain_never_consumes_restored_events(): + """The legacy consume-everything branch must fail closed on restored events.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="DEAD_SESSION", restored=True)) + + results = reg.drain_notifications() # no filter — legacy CLI post-turn shape + + assert results == [] + # Still queued for its real owner. + assert reg.completion_queue.qsize() == 1 + assert reg.completion_queue.get_nowait()["session_key"] == "DEAD_SESSION" + + +def test_unfiltered_drain_keeps_legacy_behavior_for_same_process_events(): + """Non-restored keyless events (created by this process) are still consumed.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="")) + + results = reg.drain_notifications() + + assert len(results) == 1 + assert results[0][0]["delegation_id"] == "d1" + assert reg.completion_queue.empty() + + +def test_owner_session_key_drain_consumes_restored_event(): + """The owning session (key match) still receives its restored completion.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True)) + + results = reg.drain_notifications(session_key="OWNER") + + assert len(results) == 1 + assert results[0][0]["session_key"] == "OWNER" + assert reg.completion_queue.empty() + + +def test_foreign_session_key_drain_requeues_restored_event(): + """A different session's keyed drain must not claim the restored event.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True)) + + results = reg.drain_notifications(session_key="SOMEONE_ELSE") + + assert results == [] + assert reg.completion_queue.qsize() == 1 + + +def test_owns_event_callback_beats_restored_flag(): + """A positive-proof ownership callback consumes restored events it owns.""" + reg = _make_registry() + reg.completion_queue.put(_delegation_event(session_key="OWNER", restored=True)) + + results = reg.drain_notifications( + owns_event=lambda e: e.get("session_key") == "OWNER" + ) + + assert len(results) == 1 + assert reg.completion_queue.empty() -
tools/async_delegation.py@@ -264,7 +264,17 @@ def recover_abandoned_delegations() -> int: def restore_undelivered_completions(target_queue) -> int: - """Enqueue durable pending completions as fresh turns after process start.""" + """Enqueue durable pending completions as fresh turns after process start. + + Every restored event is stamped ``restored=True`` (in-memory only — the + stamp is added after the durable payload is deserialized and is never + persisted). Restored events originate from a *previous* process, so no + consumer in THIS process implicitly owns them: drain paths that run + without an ownership filter (the legacy single-session behavior) must + leave them queued for a consumer that can positively prove ownership, + otherwise a brand-new session adopts a dead session's delegation + results seconds after boot (#64484). + """ recover_abandoned_delegations() with _DB_LOCK, _connect() as conn: rows = conn.execute( @@ -273,7 +283,10 @@ def restore_undelivered_completions(target_queue) -> int: ORDER BY completed_at, delegation_id""" ).fetchall() for _delegation_id, payload in rows: - target_queue.put(json.loads(payload)) + evt = json.loads(payload) + if isinstance(evt, dict): + evt["restored"] = True + target_queue.put(evt) return len(rows) -
tools/delegate_tool.py@@ -2844,6 +2844,20 @@ def _execute_and_aggregate() -> dict: _session_key = _agent_session_id except Exception: _origin_ui_session_id = "" + if not _session_key: + # CLI (single-process) path: the approval contextvar is only bound + # during gateway/TUI turns and HERMES_SESSION_KEY is not in the CLI + # environment, so the key resolves empty here. Since #64240 the CLI + # drains completions through a positive-ownership filter keyed on + # the durable AIAgent.session_id — an empty session_key would fail + # closed and the CLI could never claim its own completions, while + # a restored foreign event with an empty key could leak into any + # unfiltered consumer (#64484). Stamp the parent's durable session + # id instead; compression rotations are handled on the drain side + # via resolve_resume_session_id lineage resolution. + _agent_session_id = str(getattr(parent_agent, "session_id", "") or "") + if _agent_session_id: + _session_key = _agent_session_id _parent_session_id = getattr(parent_agent, "session_id", None) _child_agents = [c for (_, _, c) in children] -
tools/process_registry.py@@ -1211,6 +1211,19 @@ def drain_notifications( if evt_session_key != session_key: requeue.append(evt) continue + elif evt.get("restored"): + # Legacy unfiltered drain (no ownership callback, no + # session key). That behavior was safe when the in-memory + # queue could only hold events created by this very + # process — but durable restore (#63494) re-enqueues + # completions from PREVIOUS processes at startup, so an + # unfiltered consumer here would adopt a dead, unrelated + # session's conversation payload (#64484). Fail closed: + # leave restored events queued (still 'pending' on disk) + # for a consumer that can positively prove ownership, + # e.g. the owning session's --resume. + requeue.append(evt) + continue text = format_process_notification(evt) if text: results.append((evt, text))
-
-
#65419 fix(tui): route background-process notifications to their owning session (#35652)
files:
scripts/release.py,tests/test_tui_gateway_server.py,tests/tools/test_process_registry.py,tools/process_registry.py,tui_gateway/server.pyShow the diff
-
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "2751738943", # PR #54785 salvage (tui: post-turn completion ownership routing) "[redacted:email]": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages) "[redacted:email]": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url) "[redacted:email]": "Epoxidex", # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758) -
tests/test_tui_gateway_server.py@@ -2284,7 +2284,611 @@ def replace_messages(self, key, messages): server._sessions.pop("trunc-sid", None) -def test_session_create_does_not_persist_empty_row(monkeypatch): +class _StopAfterOneNotificationPoll: + def __init__(self): + self._checks = 0 + + def is_set(self): + self._checks += 1 + return self._checks > 1 + + +def test_notification_poller_live_loop_requeues_foreign_completion_for_owner( + monkeypatch, +): + """A foreign live-loop dequeue is handed back to its proven owner.""" + import queue as _queue_mod + + from tools.process_registry import process_registry + + delivered = {"a": [], "b": []} + emitted = [] + session_a = _session(session_key="session-a-live-handoff") + session_b = _session(session_key="session-b-live-handoff") + event = { + "type": "completion", + "session_id": "proc-live-handoff", + "session_key": "session-a-live-handoff", + "command": "echo owner", + "exit_code": 0, + "output": "owner", + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + monkeypatch.setattr(server, "_get_db", lambda: None) + monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args)) + + def _deliver(_rid, sid, session, text): + delivered["a" if sid == "sid-a-live-handoff" else "b"].append(text) + session["running"] = False + + monkeypatch.setattr(server, "_run_prompt_submit", _deliver) + server._sessions.update( + { + "sid-a-live-handoff": session_a, + "sid-b-live-handoff": session_b, + } + ) + process_registry._completion_consumed.discard(event["session_id"]) + + try: + server._notification_poller_loop( + _StopAfterOneNotificationPoll(), "sid-b-live-handoff", session_b + ) + + assert delivered["b"] == [] + assert emitted == [] + assert isolated_queue.qsize() == 1 + assert isolated_queue.queue[0] is event + + server._notification_poller_loop( + _StopAfterOneNotificationPoll(), "sid-a-live-handoff", session_a + ) + + assert len(delivered["a"]) == 1 + assert "proc-live-handoff completed normally" in delivered["a"][0] + assert delivered["b"] == [] + assert isolated_queue.empty() + finally: + server._sessions.pop("sid-a-live-handoff", None) + server._sessions.pop("sid-b-live-handoff", None) + process_registry._completion_consumed.discard(event["session_id"]) + while not isolated_queue.empty(): + isolated_queue.get_nowait() + + +def test_completion_ownership_lineage_lookup_failure_fails_closed(monkeypatch): + """A provenance lookup failure cannot turn an addressed event into ours.""" + import queue as _queue_mod + + from tools.process_registry import process_registry + + class _BrokenDB: + def resolve_resume_session_id(self, _session_key): + raise RuntimeError("lineage database unavailable") + + session = _session(session_key="unrelated-live-session") + event = { + "type": "completion", + "session_id": "proc-unknown-lineage", + "session_key": "unknown-parent", + "command": "echo unknown", + "exit_code": 0, + "output": "unknown", + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB()) + + drained = process_registry.drain_notifications( + session_key="unrelated-live-session", + owns_event=lambda candidate: server._session_owns_notification_event( + "sid-unrelated-live", session, candidate + ), + ) + + assert drained == [] + assert isolated_queue.qsize() == 1 + assert isolated_queue.get_nowait() is event + + [redacted:email]( + "routing", + [ + {"session_key": "missing-owner-key"}, + {"origin_ui_session_id": "missing-owner-sid"}, + ], +) +def test_notification_poller_live_loop_drops_addressed_orphan( + monkeypatch, routing +): + """A live poll never injects an addressed event whose owner is gone.""" + import queue as _queue_mod + + from tools.process_registry import process_registry + + delivered = [] + emitted = [] + session = _session(session_key="unrelated-live-key") + event = { + "type": "completion", + "session_id": "proc-live-orphan", + "command": "echo orphan", + "exit_code": 0, + "output": "orphan", + **routing, + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + monkeypatch.setattr(server, "_get_db", lambda: None) + monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args)) + monkeypatch.setattr( + server, + "_run_prompt_submit", + lambda _rid, _sid, _session, text: delivered.append(text), + ) + server._sessions["sid-live-orphan"] = session + process_registry._completion_consumed.discard(event["session_id"]) + + try: + server._notification_poller_loop( + _StopAfterOneNotificationPoll(), "sid-live-orphan", session + ) + + assert delivered == [] + assert emitted == [] + assert isolated_queue.empty() + finally: + server._sessions.pop("sid-live-orphan", None) + process_registry._completion_consumed.discard(event["session_id"]) + while not isolated_queue.empty(): + isolated_queue.get_nowait() + + [redacted:email]( + "routing", + [ + {"session_key": "session-b"}, + {"origin_ui_session_id": "sid_gone"}, + ], +) +def test_notification_poller_drops_orphaned_events(monkeypatch, routing): + """Addressed completions whose owner is gone are dropped, not hijacked.""" + import queue as _queue_mod + + from tools.process_registry import process_registry + + emitted = [] + delivered = [] + sess = _session(session_key="session-a") + server._sessions["sid_a"] = sess + monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) + monkeypatch.setattr( + server, + "_run_prompt_submit", + lambda _rid, _sid, _session, text: delivered.append(text), + ) + monkeypatch.setattr(server, "_get_db", lambda: None) + + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + process_registry._completion_consumed.discard("proc_ghost") + isolated_queue.put( + { + "type": "completion", + "session_id": "proc_ghost", + "command": "echo from ghost", + "exit_code": 0, + "output": "ghost output", + **routing, + } + ) + + stop = threading.Event() + stop.set() + + try: + server._notification_poller_loop(stop, "sid_a", sess) + + assert [a for a in emitted if a[0] == "status.update"] == [] + assert delivered == [] + finally: + server._sessions.pop("sid_a", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + [redacted:email]( + ("routing", "resolved_key"), + [ + ({"session_key": "session-a"}, None), + ( + { + "session_key": "stale-durable-key", + "origin_ui_session_id": "sid_a", + }, + None, + ), + ({"session_key": "old-parent-key"}, "session-a"), + ], +) +def test_notification_poller_delivers_owned_events( + monkeypatch, routing, resolved_key +): + """Direct, UI-origin, and compression-lineage owners are delivered.""" + import queue as _queue_mod + + from tools.process_registry import process_registry + + class _CompressionDB: + def resolve_resume_session_id(self, key): + return resolved_key if key == "old-parent-key" and resolved_key else key + + delivered = [] + emitted = [] + sess = _session(session_key="session-a") + server._sessions["sid_a"] = sess + monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) + monkeypatch.setattr( + server, + "_run_prompt_submit", + lambda _rid, _sid, _session, text: delivered.append(text), + ) + monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB()) + + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + process_registry._completion_consumed.discard("proc_mine") + isolated_queue.put( + { + "type": "completion", + "session_id": "proc_mine", + "command": "echo mine", + "exit_code": 0, + "output": "mine", + **routing, + } + ) + + stop = threading.Event() + stop.set() + + try: + server._notification_poller_loop(stop, "sid_a", sess) + + status_calls = [a for a in emitted if a[0] == "status.update"] + assert len(status_calls) == 1 + assert status_calls[0][2]["kind"] == "process" + assert len(delivered) == 1 + assert "proc_mine" in delivered[0] + finally: + server._sessions.pop("sid_a", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def _configure_immediate_prompt_run( + monkeypatch, tmp_path, *, immediate_threads=True +): + class _ImmediateThread: + def __init__(self, target=None, daemon=None, **_kwargs): + self._target = target + + def start(self): + if self._target is not None: + self._target() + + def is_alive(self): + return False + + if immediate_threads: + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda _cols: None) + monkeypatch.setattr(server, "render_message", lambda _raw, _cols: None) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda *_args: None) + monkeypatch.setattr(server, "_session_cwd", lambda _session: str(tmp_path)) + monkeypatch.setattr(server, "_register_session_cwd", lambda _session: None) + monkeypatch.setattr(server, "_set_session_context", lambda *_args, **_kwargs: []) + monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) + monkeypatch.setattr(server, "_session_info", lambda *_args: {}) + monkeypatch.setattr(server, "_get_usage", lambda _agent: {}) + monkeypatch.setattr( + server, "_sync_session_key_after_compress", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr(server, "_drain_queued_prompt", lambda *_args: False) + monkeypatch.setattr(server, "_voice_tts_enabled", lambda: False) + monkeypatch.setattr(server, "_get_db", lambda: None) + + +class _RecordingAgent: + model = "test-model" + provider = "test-provider" + + def __init__(self, turns): + self._turns = turns + + def clear_interrupt(self): + return None + + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + self._turns.append(prompt) + return {"final_response": "", "messages": []} + + [redacted:email]("exit_code", [0, 7]) +def test_run_prompt_submit_requeues_foreign_completion( + monkeypatch, tmp_path, exit_code +): + import queue as _queue_mod + + from tools.process_registry import process_registry + + _configure_immediate_prompt_run(monkeypatch, tmp_path) + turns = [] + session_a = _session(session_key="session-a") + session_b = _session( + session_key="session-b", + agent=_RecordingAgent(turns), + running=True, + ) + event = { + "type": "completion", + "session_id": f"proc_foreign_{exit_code}", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": exit_code, + "output": "foreign", + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + server._sessions["sid_a"] = session_a + server._sessions["sid_b"] = session_b + + try: + server._run_prompt_submit("rid-b", "sid_b", session_b, "session-b-turn") + + assert turns == ["session-b-turn"] + assert isolated_queue.get_nowait() == event + assert isolated_queue.empty() + finally: + server._sessions.pop("sid_a", None) + server._sessions.pop("sid_b", None) + process_registry._completion_consumed.discard(event["session_id"]) + + +def test_run_prompt_submit_delivers_completion_observed_by_poll(monkeypatch, tmp_path): + import queue as _queue_mod + + from tools.process_registry import process_registry + + _configure_immediate_prompt_run(monkeypatch, tmp_path) + turns = [] + session = _session( + session_key="session-a", + agent=_RecordingAgent(turns), + running=True, + ) + event = { + "type": "completion", + "session_id": "proc_polled", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": "observed but not consumed", + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + process_registry._completion_consumed.discard(event["session_id"]) + process_registry._poll_observed.add(event["session_id"]) + server._sessions["sid_a"] = session + + try: + server._run_prompt_submit("rid-a", "sid_a", session, "session-a-turn") + + assert turns[0] == "session-a-turn" + assert len(turns) == 2 + assert "proc_polled" in turns[1] + assert isolated_queue.empty() + finally: + server._sessions.pop("sid_a", None) + process_registry._completion_consumed.discard(event["session_id"]) + process_registry._poll_observed.discard(event["session_id"]) + + +def test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading( + monkeypatch, tmp_path +): + import queue as _queue_mod + + from tools.process_registry import process_registry + + _configure_immediate_prompt_run( + monkeypatch, tmp_path, immediate_threads=False + ) + real_thread_class = threading.Thread + threads = [] + nested_started = threading.Event() + release_nested = threading.Event() + turns = [] + + def _recording_thread(*args, **kwargs): + thread = real_thread_class(*args, **kwargs) + threads.append(thread) + return thread + + class _BlockingNotificationAgent(_RecordingAgent): + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + turns.append(prompt) + if "proc_batch_1" in prompt: + nested_started.set() + if not release_nested.wait(timeout=5): + raise TimeoutError("notification turn was not released") + return {"final_response": "", "messages": []} + + monkeypatch.setattr(server.threading, "Thread", _recording_thread) + session = _session( + session_key="session-a", + agent=_BlockingNotificationAgent(turns), + running=True, + ) + events = [ + { + "type": "completion", + "session_id": f"proc_batch_{index}", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": f"owned-{index}", + } + for index in range(1, 4) + ] + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + for event in events: + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + server._sessions["sid_a"] = session + + try: + server._run_prompt_submit("rid-a", "sid_a", session, "session-a-turn") + + assert nested_started.wait(timeout=5) + threads[0].join(timeout=5) + assert not threads[0].is_alive() + queued = [] + while not isolated_queue.empty(): + queued.append(isolated_queue.get_nowait()) + assert [event["session_id"] for event in queued] == [ + "proc_batch_2", + "proc_batch_3", + ] + finally: + release_nested.set() + for thread in threads: + thread.join(timeout=5) + server._sessions.pop("sid_a", None) + while not isolated_queue.empty(): + isolated_queue.get_nowait() + for event in events: + process_registry._completion_consumed.discard(event["session_id"]) + process_registry._poll_observed.discard(event["session_id"]) + + +def test_run_prompt_submit_delivers_completion_owned_through_compression_lineage( + monkeypatch, tmp_path +): + import queue as _queue_mod + + from tools.process_registry import process_registry + + class _CompressionDB: + def resolve_resume_session_id(self, key): + return "new-child-key" if key == "old-parent-key" else key + + _configure_immediate_prompt_run(monkeypatch, tmp_path) + monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB()) + ownership_checks = [] + original_owns_event = server._session_owns_notification_event + + def _record_ownership_check(sid, checked_session, checked_event): + ownership_checks.append(checked_event["session_id"]) + return original_owns_event(sid, checked_session, checked_event) + + monkeypatch.setattr( + server, "_session_owns_notification_event", _record_ownership_check + ) + turns = [] + session = _session( + session_key="new-child-key", + agent=_RecordingAgent(turns), + running=True, + ) + event = { + "type": "completion", + "session_id": "proc_precompression", + "session_key": "old-parent-key", + "command": "safe-test-command", + "exit_code": 0, + "output": "owned", + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + server._sessions["sid_b"] = session + + try: + server._run_prompt_submit("rid-b", "sid_b", session, "session-b-turn") + + assert turns[0] == "session-b-turn" + assert len(turns) == 2 + assert "proc_precompression" in turns[1] + assert ownership_checks == ["proc_precompression"] + assert isolated_queue.empty() + finally: + server._sessions.pop("sid_b", None) + process_registry._completion_consumed.discard(event["session_id"]) + + +def test_run_prompt_submit_prefers_origin_ui_session_id(monkeypatch, tmp_path): + import queue as _queue_mod + + from tools.process_registry import process_registry + + _configure_immediate_prompt_run(monkeypatch, tmp_path) + ownership_checks = [] + original_owns_event = server._session_owns_notification_event + + def _record_ownership_check(sid, checked_session, checked_event): + ownership_checks.append(checked_event["session_id"]) + return original_owns_event(sid, checked_session, checked_event) + + monkeypatch.setattr( + server, "_session_owns_notification_event", _record_ownership_check + ) + turns = [] + session = _session( + session_key="current-key", + agent=_RecordingAgent(turns), + running=True, + ) + event = { + "type": "completion", + "session_id": "proc_origin_owned", + "session_key": "stale-durable-key", + "origin_ui_session_id": "sid_b", + "command": "safe-test-command", + "exit_code": 0, + "output": "owned", + } + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + isolated_queue.put(event) + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) + server._sessions["sid_b"] = session + + try: + server._run_prompt_submit("rid-b", "sid_b", session, "session-b-turn") + + assert turns[0] == "session-b-turn" + assert len(turns) == 2 + assert "proc_origin_owned" in turns[1] + assert ownership_checks == ["proc_origin_owned"] + assert isolated_queue.empty() + finally: + server._sessions.pop("sid_b", None) + process_registry._completion_consumed.discard(event["session_id"]) + + + """session.create must NOT eagerly write a DB row. Every TUI/desktop launch opens a session here just to paint the composer; -
tests/tools/test_process_registry.py@@ -1336,6 +1336,66 @@ def test_drain_notifications_skips_consumed(): process_registry.completion_queue.get_nowait() +def test_drain_notifications_can_deliver_poll_observed_for_gateway(registry): + event = { + "type": "completion", + "session_id": "proc_polled", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": "observed but not consumed", + } + registry._poll_observed.add(event["session_id"]) + registry.completion_queue.put(event) + + try: + results = registry.drain_notifications( + session_key="session-a", + owns_event=lambda _event: True, + skip_poll_observed=False, + ) + + assert [raw for raw, _ in results] == [event] + finally: + registry._poll_observed.discard(event["session_id"]) + + [redacted:email]( + "skip_state", ["_poll_observed", "_completion_consumed"] +) +def test_drain_notifications_routes_foreign_before_local_skip( + registry, skip_state +): + event = { + "type": "completion", + "session_id": f"proc_foreign_{skip_state}", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": "foreign", + } + ownership_calls = [] + getattr(registry, skip_state).add(event["session_id"]) + registry.completion_queue.put(event) + + def owns_event(checked_event): + ownership_calls.append(checked_event) + return False + + try: + results = registry.drain_notifications( + session_key="session-b", + owns_event=owns_event, + ) + + assert results == [] + assert ownership_calls == [event] + assert registry.completion_queue.get_nowait() == event + assert registry.completion_queue.empty() + finally: + getattr(registry, skip_state).discard(event["session_id"]) + + def test_drain_notifications_empty_queue(): from tools.process_registry import process_registry @@ -1346,6 +1406,151 @@ def test_drain_notifications_empty_queue(): assert results == [] [redacted:email]("exit_code", [0, 7]) +def test_drain_notifications_filters_addressed_completion_by_owns_event( + registry, exit_code +): + owned = { + "type": "completion", + "session_id": f"proc_owned_{exit_code}", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": exit_code, + "output": "owned", + } + foreign = { + "type": "completion", + "session_id": f"proc_foreign_{exit_code}", + "session_key": "session-b", + "command": "safe-test-command", + "exit_code": exit_code, + "output": "foreign", + } + registry.completion_queue.put(owned) + registry.completion_queue.put(foreign) + + results = registry.drain_notifications( + session_key="session-a", + owns_event=lambda event: event.get("session_key") == "session-a", + ) + + assert [event["session_id"] for event, _ in results] == [ + f"proc_owned_{exit_code}" + ] + assert registry.completion_queue.get_nowait() == foreign + assert registry.completion_queue.empty() + + +def test_drain_notifications_filters_addressed_completion_by_session_key(registry): + owned = { + "type": "completion", + "session_id": "proc_owned", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": "owned", + } + foreign = { + "type": "completion", + "session_id": "proc_foreign", + "session_key": "session-b", + "command": "safe-test-command", + "exit_code": 0, + "output": "foreign", + } + registry.completion_queue.put(owned) + registry.completion_queue.put(foreign) + + results = registry.drain_notifications(session_key="session-a") + + assert [event["session_id"] for event, _ in results] == ["proc_owned"] + assert registry.completion_queue.get_nowait() == foreign + assert registry.completion_queue.empty() + + +def test_drain_notifications_session_key_filter_requeues_origin_only_event(registry): + event = { + "type": "completion", + "session_id": "proc_origin_only", + "origin_ui_session_id": "ui-session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": "done", + } + registry.completion_queue.put(event) + + results = registry.drain_notifications(session_key="session-a") + + assert results == [] + assert registry.completion_queue.get_nowait() == event + assert registry.completion_queue.empty() + + +def test_drain_notifications_ownerless_completion_preserves_legacy_delivery(registry): + event = { + "type": "completion", + "session_id": "proc_ownerless", + "command": "safe-test-command", + "exit_code": 0, + "output": "ownerless", + } + registry.completion_queue.put(event) + + results = registry.drain_notifications( + session_key="session-a", + owns_event=lambda _event: False, + ) + + assert [raw for raw, _ in results] == [event] + assert registry.completion_queue.empty() + + +def test_drain_notifications_ownerless_async_delegation_still_requires_proof(registry): + event = { + "type": "async_delegation", + "delegation_id": "deleg_ownerless", + "goal": "task", + "status": "completed", + "summary": "done", + "api_calls": 1, + "duration_seconds": 0.1, + } + registry.completion_queue.put(event) + + results = registry.drain_notifications( + session_key="session-a", + owns_event=lambda _event: False, + ) + + assert results == [] + assert registry.completion_queue.get_nowait() == event + assert registry.completion_queue.empty() + + +def test_drain_notifications_completion_callback_exception_fails_closed(registry): + event = { + "type": "completion", + "session_id": "proc_callback_error", + "session_key": "session-a", + "command": "safe-test-command", + "exit_code": 0, + "output": "done", + } + registry.completion_queue.put(event) + + def broken(_event): + raise RuntimeError("ownership check exploded") + + results = registry.drain_notifications( + session_key="session-a", + owns_event=broken, + ) + + assert results == [] + assert registry.completion_queue.get_nowait() == event + assert registry.completion_queue.empty() + + def test_drain_notifications_filters_async_delegation_by_session_key(): """Async-delegation events should only be consumed by the matching session's drain. -
tools/process_registry.py@@ -1146,8 +1146,10 @@ def is_session_waiting(self, session_id: str) -> bool: return False return True - def _drain_should_skip(self, session_id: str) -> bool: - """Whether the CLI drain should skip a completion event for this session. + def _drain_should_skip( + self, session_id: str, *, skip_poll_observed: bool = True + ) -> bool: + """Whether this drain should skip a completion event for this session. Skips when the agent has either truly consumed the output (wait/log → ``_completion_consumed``) or observed the exit inline via poll() @@ -1157,32 +1159,45 @@ def _drain_should_skip(self, session_id: str) -> bool: check only ``is_completion_consumed`` so a read-only poll never suppresses their autonomous delivery turn (#10156). """ - return session_id in self._completion_consumed or session_id in self._poll_observed + return session_id in self._completion_consumed or ( + skip_poll_observed and session_id in self._poll_observed + ) def drain_notifications( - self, session_key: str = "", owns_event=None, + self, + session_key: str = "", + owns_event=None, + *, + skip_poll_observed: bool = True, ) -> "list[tuple[dict, str]]": """Pop all pending notification events and return formatted pairs. Returns a list of (raw_event, formatted_text) tuples. Skips completion events the agent already consumed via wait/log or - observed inline via poll() (see ``_drain_should_skip``). + observed inline via poll() (see ``_drain_should_skip``). Gateway/TUI + callers pass ``skip_poll_observed=False`` because read-only polling must + not suppress autonomous delivery there. - Async-delegation events carry a conversation payload, so draining one - into the wrong session is a cross-chat leak (#58684, #55578). Two - filter modes, strongest wins: + When a routing filter is supplied, addressed notifications must not be + drained into the wrong session. Async-delegation events always require + conversation payload; ordinary notifications require routing when they + carry ``session_key`` or ``origin_ui_session_id`` metadata. Two filter + modes are supported, strongest first: - ``owns_event(evt) -> bool``: positive-proof ownership callback. - When provided, an async-delegation event is consumed ONLY if the - callback returns True; everything else is re-queued for its owner. + When provided, a routed event is consumed ONLY if the callback + returns True; everything else is re-queued for its owner. The TUI passes its compression-chain-aware ownership check here so a post-compression session still claims its own pre-compression dispatches. - ``session_key``: plain key equality (CLI and other single-session - callers). Non-matching async-delegation events are re-queued. + callers). Non-matching addressed events are re-queued. With neither set, all events are consumed (legacy single-session - behavior, backward compatible). + behavior, backward compatible). Ownerless ordinary notifications also + retain that legacy behavior even when a filter is provided. When a + filter is provided, ownerless async-delegation events remain + fail-closed and require positive proof. """ results: "list[tuple[dict, str]]" = [] requeue: "list[dict]" = [] @@ -1191,39 +1206,43 @@ def drain_notifications( evt = self.completion_queue.get_nowait() except Exception: break - _evt_sid = evt.get("session_id", "") - if evt.get("type") == "completion" and self._drain_should_skip(_evt_sid): - continue - # Filter async-delegation events so they are not delivered to the - # wrong session/thread (#58684). Positive-proof callback beats - # bare key equality when the caller can provide one. - if evt.get("type") == "async_delegation": - if owns_event is not None: - try: - owned = bool(owns_event(evt)) - except Exception: - owned = False # fail closed — never leak on a broken check - if not owned: - requeue.append(evt) - continue - elif session_key: - evt_session_key = evt.get("session_key", "") or "" - if evt_session_key != session_key: - requeue.append(evt) - continue - elif evt.get("restored"): - # Legacy unfiltered drain (no ownership callback, no - # session key). That behavior was safe when the in-memory - # queue could only hold events created by this very - # process — but durable restore (#63494) re-enqueues - # completions from PREVIOUS processes at startup, so an - # unfiltered consumer here would adopt a dead, unrelated - # session's conversation payload (#64484). Fail closed: - # leave restored events queued (still 'pending' on disk) - # for a consumer that can positively prove ownership, - # e.g. the owning session's --resume. + # Positive-proof ownership beats bare key equality. Delegation + # payloads always require proof; ordinary events require it once + # they carry routing metadata. Ownerless ordinary events preserve + # legacy single-session delivery. + is_async_delegation = evt.get("type") == "async_delegation" + evt_session_key = str(evt.get("session_key") or "") + evt_origin_sid = str(evt.get("origin_ui_session_id") or "") + requires_positive_proof = is_async_delegation or bool( + evt_session_key or evt_origin_sid + ) + if owns_event is not None and requires_positive_proof: + try: + owned = bool(owns_event(evt)) + except Exception: + owned = False # fail closed — never leak on a broken check + if not owned: + requeue.append(evt) + continue + elif session_key and requires_positive_proof: + if evt_session_key != session_key: requeue.append(evt) continue + elif is_async_delegation and evt.get("restored"): + # Durable restore can enqueue previous-process payloads into a + # fresh registry. An unfiltered legacy drain cannot prove + # ownership, so leave those events queued for the owner. + requeue.append(evt) + continue + # Local consumed/observed state may suppress only events this + # session owns (or legacy ownerless ordinary events). Routing must + # happen first so a foreign session cannot drop the owner's event. + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and self._drain_should_skip( + _evt_sid, skip_poll_observed=skip_poll_observed + ): + continue + text = format_process_notification(evt) if text: results.append((evt, text)) -
tui_gateway/server.py@@ -8652,9 +8652,9 @@ def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool minus its orphan-adoption fallback. An event owns-matches when its ``origin_ui_session_id`` is this live session, or its ``session_key`` (raw or resolved through the compression chain) matches this session's - key/lineage. Used as a fail-closed gate for async-delegation payloads: - "not provably elsewhere" is NOT good enough to inject a conversation - payload into this chat (#55578). + key/lineage. Used as the fail-closed gate for every addressed notification: + "not provably elsewhere" is NOT good enough to inject a payload into this + chat (#55578). """ if session.get("_finalized"): return False @@ -8679,6 +8679,14 @@ def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool return resolved_key in current_keys +def _notification_event_requires_owner(evt: dict) -> bool: + """Whether ``evt`` must be positively claimed before TUI delivery.""" + return evt.get("type") == "async_delegation" or bool( + str(evt.get("origin_ui_session_id") or "") + or str(evt.get("session_key") or "") + ) + + def _notification_event_dedup_key(evt: dict) -> tuple: """Return the UI-emission identity for a process notification event. @@ -8725,10 +8733,9 @@ def _notification_poller_loop( status.update (kind=process) for user visibility, then chains an agent turn via _run_prompt_submit if the session is idle. - NOTE: The completion_queue is global (one per process). If multiple - TUI sessions coexist, whichever poller wakes first grabs the event, - even if the process was started by a different session. This matches - CLI/gateway behavior (single session per process). + The completion_queue is process-global. In multi-session Desktop each + poller requeues events owned by another live session and drops addressed + events whose owner is gone; ownerless legacy notifications remain global. """ from tools.process_registry import process_registry, format_process_notification @@ -8749,26 +8756,22 @@ def _notification_poller_loop( time.sleep(0.1) continue - # Fail closed for async-delegation results (#55578): these carry a - # conversation payload, and injecting one into any chat other than the - # one that commissioned it is a hard cross-session leak. The - # belongs-elsewhere check above already re-queued events owned by - # another LIVE session; what reaches here is either ours or an - # orphan whose owner is gone. Orphaned delegation payloads are - # DROPPED, not adopted — the subagent's summary is already persisted - # in the delegation records/output store, so nothing is lost, whereas - # a wrong-chat injection is unrecoverable. Non-delegation events - # (background process completions etc.) keep the historical - # adopt-orphans behavior. - if evt.get("type") == "async_delegation" and not _session_owns_notification_event( - sid, session, evt - ): - logger.warning( - "async-delegation completion %s has no live owner " - "(origin=%r key=%r); dropping from injection instead of " - "delivering to session %s (#55578 fail-closed; result " - "remains in the delegation records)", - evt.get("delegation_id", "?"), + # What reaches here is not owned by another LIVE session. Addressed + # events still require positive proof before injection: exact UI origin, + # direct durable key, or compression lineage. If none proves ownership, + # the event is orphaned and must not be adopted by this chat. Truly + # ownerless ordinary notifications retain legacy global delivery. + requires_owner = _notification_event_requires_owner(evt) + if requires_owner and not _session_owns_notification_event(sid, session, evt): + log = ( + logger.warning + if evt.get("type") == "async_delegation" + else logger.debug + ) + log( + "Dropping unowned %s notification (origin=%r key=%r) instead " + "of delivering to session %s", + evt.get("type", "completion"), str(evt.get("origin_ui_session_id") or ""), str(evt.get("session_key") or ""), sid, @@ -8830,6 +8833,7 @@ def _notification_poller_loop( # Drain any remaining events after stop signal (process all pending # before exiting so nothing is lost on shutdown). Events owned by other # live sessions are set aside and re-queued so their poller still sees them. + # Orphaned events (owner gone) are dropped — same guard as the main loop. deferred: list = [] while not process_registry.completion_queue.empty(): try: @@ -8839,14 +8843,21 @@ def _notification_poller_loop( if _notification_event_belongs_elsewhere(sid, session, evt): deferred.append(evt) continue - # Same fail-closed rule as the live loop: an orphaned async-delegation - # payload is never adopted by a foreign session — defer it (a later - # resume of the owner's lineage can still claim it) rather than - # injecting another chat's conversation here (#55578). - if evt.get("type") == "async_delegation" and not _session_owns_notification_event( - sid, session, evt - ): - deferred.append(evt) + # Same positive-proof rule as the live loop. Preserve the existing + # shutdown behavior for orphaned delegation payloads by deferring them + # for a later resume; ordinary addressed orphans are dropped. + requires_owner = _notification_event_requires_owner(evt) + if requires_owner and not _session_owns_notification_event(sid, session, evt): + if evt.get("type") == "async_delegation": + deferred.append(evt) + else: + logger.debug( + "Dropping unowned %s notification during shutdown drain " + "(origin=%r key=%r)", + evt.get("type", "completion"), + str(evt.get("origin_ui_session_id") or ""), + str(evt.get("session_key") or ""), + ) continue _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): @@ -9411,21 +9422,29 @@ def _stream(delta): # Drain completion notifications that arrived during this turn. # The background poller handles between-turn delivery; this is # the safety net for events that arrived mid-turn. + # + # Ownership filter (#42674, #35652): a turn finishing in session B + # must not consume an event that belongs to session A. The registry + # requeues every addressed event this session cannot positively claim; + # the poller then delivers it to a live owner or drops an orphan. try: from tools.process_registry import process_registry # Positive-proof ownership (compression-chain aware) — the same # fail-closed gate the poller uses, so the post-turn drain can't - # adopt another session's (or an orphan's) delegation payload, - # while a post-compression session still claims its own - # pre-compression dispatches (#55578). - for _evt, synth in process_registry.drain_notifications( + # adopt another session's addressed notification while a + # post-compression session still claims its own pre-compression + # dispatches (#55578). + drained = process_registry.drain_notifications( session_key=session.get("session_key", ""), owns_event=lambda e: _session_owns_notification_event(sid, session, e), - ): + skip_poll_observed=False, + ) + for index, (_evt, synth) in enumerate(drained): with session["history_lock"]: if session.get("running"): - process_registry.completion_queue.put(_evt) + for pending_evt, _pending_synth in drained[index:]: + process_registry.completion_queue.put(pending_evt) break session["running"] = True from tools.async_delegation import (
-
15 PRs closed without merging
From the close comments: merged via another change 7 · superseded by another PR 5 · done elsewhere 2 · partial fix 1
-
#1573 fix(gateway): persist watcher metadata in checkpoint for crash recovery · merged via another change →
#1706 -
#7391 fix(gateway): replace os.environ session state with contextvars to prevent concurrent message cross-contamination · merged via another change →
#7454 -
#9537 fix(gateway): route synthetic background events to their originating session · merged via another change →
#10460,#10446 -
#10446 fix(gateway): route watch-pattern notifications to originating session · merged via another change →
#10460,#9537 -
#26945 fix(telegram): preserve DM topic routing for synthetic background process events · superseded by another PR →
#27212,#9532,#27937
Show 10 more
-
#27212 fix(telegram): reply-anchor background-process notifications into DM topics · merged via another change →
#26945 -
#35667 fix(tui): route completion notifications to owner live session · superseded by another PR →
#60863 -
#35672 fix(tui): route process notifications to owner session · done elsewhere →
85b65e2,#37099,#35667 -
#42731 fix(tui): keep background notifications in owning session (#42674) · superseded by another PR →
#65419 -
#54785 fix(tui): route bg process notifications to owning session, drop orphaned events · partial fix →
35803dd,569b912,#54785,#63317,#42731,#35667,#57586,#60863,#64484 -
#57535 fix(delegate): pin async completion to spawning parent session (#57498) · merged via another change →
#60871 -
#57586 fix(tui): route async delegation results across the compression chain · superseded by another PR →
#60863,#59767 -
#58690 fix(delegation): route async delegate_task results back to originating session · merged via another change →
#60869 -
#60441 fix(gateway): prevent background process notifications from being injected into wrong sessions · done elsewhere →
dee592a,#60426,#42674 -
#63317 fix(tui): enforce notification session ownership across all drain paths · superseded by another PR →
#54785
Closed issues · 15
- Closed#1143Telegram background process notifications ignore forum topic and post to General
- Closed#7358Bug: Concurrent gateway messages cause cross-thread misrouting of background task notifications (os.environ state leakage)
- Closed#9532[Bug]: background process watch notifications can leak into the wrong threaded session
- Closed#9736Bug: Background process completion notifications can route to the wrong session
- Closed#10411[Bug]: Background watch_match notifications can be injected into the wrong Discord thread
- Closed#18101[Bug]: Background process updates can leak into the wrong Slack thread
- Closed#35652TUI: background process completion notifications can route to the wrong live session
- Closed#42674[Bug]: Background process notify_on_complete leaks into wrong TUI session (cross-session bleed)
- Closed#52447Background process notifications route to active session instead of originating session
- Closed#57498delegate_task background results routed to wrong session (cross-channel contamination)
- Closed#57502Bug: delegate_task sub-agent results routed to wrong session (parent session ID lost on write-back)
- Closed#57576Async delegation result delivered to wrong TUI session after context compression
- Closed#58684bug: delegate_task async results delivered to wrong session/thread
- Closed#60426Background process notifications routed to wrong session
- Closed#64484[Bug]: New CLI session adopts a dead session's async delegation completions (durable restore bypasses CLI drain ownership)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab