{"repo":"NousResearch/hermes-agent","public_id":"57576","display_label":"Async delegation results routed to wrong TUI session after compression","summary":"The three members center on async delegation results being delivered to the wrong TUI session after context compression. One is an issue describing the misrouting, and two are pull requests proposing routing changes across the compression chain so results reach their origin session.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60863,"merged_at":"2026-07-08T14:06:15+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-08T14:06:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":35652,"pr_number":60863,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T14:06:15+00:00"},{"issue_number":57576,"pr_number":60863,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T14:06:15+00:00"}],"members":[{"kind":"issue","number":57576,"title":"Async delegation result delivered to wrong TUI session after context compression","url":null,"state":"closed"},{"kind":"pr","number":57586,"title":"fix(tui): route async delegation results across the compression chain","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60863","number":60863},{"kind":"pr","value":"#59767","number":59767}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":60863,"title":"fix(delegation): route async results to their origin session across compression (#55578)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"gateway/session_context.py","hunks":["@@ -79,6 +79,13 @@ def session_context_engaged() -> bool:\n _SESSION_USER_NAME: ContextVar = ContextVar(\"HERMES_SESSION_USER_NAME\", default=_UNSET)\n _SESSION_KEY: ContextVar = ContextVar(\"HERMES_SESSION_KEY\", default=_UNSET)\n _SESSION_ID: ContextVar = ContextVar(\"HERMES_SESSION_ID\", default=_UNSET)\n+# In-process UI session/window id for multi-session desktop/TUI hosts. This is\n+# intentionally separate from HERMES_SESSION_ID: the latter is the durable\n+# conversation/session-db id, while the UI id is the live frontend tab/window\n+# that commissioned a detached completion. Background completions use it as a\n+# precise return address so a stale/rotated durable session key cannot be\n+# consumed by whichever desktop poller wakes first.\n+_SESSION_UI_SESSION_ID: ContextVar = ContextVar(\"HERMES_UI_SESSION_ID\", default=_UNSET)\n # ID of the message that triggered the current turn. Used as a reply anchor\n # so background-process notifications stay inside the originating Telegram\n # private-chat topic (those lanes route only with thread id + reply anchor).","@@ -123,6 +130,7 @@ def session_context_engaged() -> bool:\n     \"HERMES_SESSION_USER_NAME\": _SESSION_USER_NAME,\n     \"HERMES_SESSION_KEY\": _SESSION_KEY,\n     \"HERMES_SESSION_ID\": _SESSION_ID,\n+    \"HERMES_UI_SESSION_ID\": _SESSION_UI_SESSION_ID,\n     \"HERMES_SESSION_MESSAGE_ID\": _SESSION_MESSAGE_ID,\n     \"HERMES_SESSION_PROFILE\": _SESSION_PROFILE,\n     \"HERMES_CRON_AUTO_DELIVER_PLATFORM\": _CRON_AUTO_DELIVER_PLATFORM,","@@ -160,6 +168,7 @@ def set_session_vars(\n     profile: str = \"\",\n     cwd: str = \"\",\n     async_delivery: bool = True,\n+    ui_session_id: str = \"\",\n ) -> list:\n     \"\"\"Set all session context variables and return reset tokens.\n ","@@ -191,6 +200,7 @@ def set_session_vars(\n         _SESSION_USER_NAME.set(user_name),\n         _SESSION_KEY.set(session_key),\n         _SESSION_ID.set(session_id),\n+        _SESSION_UI_SESSION_ID.set(ui_session_id),\n         _SESSION_MESSAGE_ID.set(message_id),\n         _SESSION_PROFILE.set(profile),\n         _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),","@@ -225,6 +235,7 @@ def clear_session_vars(tokens: list) -> None:\n         _SESSION_USER_NAME,\n         _SESSION_KEY,\n         _SESSION_ID,\n+        _SESSION_UI_SESSION_ID,\n         _SESSION_MESSAGE_ID,\n         _SESSION_PROFILE,\n     ):"]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -2112,14 +2112,85 @@ def test_notification_event_routing_by_session_key(monkeypatch):\n     monkeypatch.setattr(server, \"_sessions\", {\"a\": mine, \"b\": other})\n \n     # My own event → handle it.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"mine\"}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"mine\"}) is False\n     # Global/system event with no owner → handle it.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"\"}) is False\n-    assert server._notification_event_belongs_elsewhere(mine, {}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"\"}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {}) is False\n     # Owned by another *live* session → defer to that session's poller.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"other\"}) is True\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"other\"}) is True\n     # Owner is gone (not in _sessions) → handle as fallback so it isn't lost.\n-    assert server._notification_event_belongs_elsewhere(mine, {\"session_key\": \"ghost\"}) is False\n+    assert server._notification_event_belongs_elsewhere(\"a\", mine, {\"session_key\": \"ghost\"}) is False\n+\n+\n+def test_async_delegation_event_prefers_origin_ui_session(monkeypatch):\n+    \"\"\"Detached subagent completions return to the commissioning TUI tab.\n+\n+    Regression: when the durable session key was stale/orphaned, whichever\n+    desktop poller woke first could consume the async result and inject it into\n+    an unrelated session.\n+    \"\"\"\n+    mine = _session(session_key=\"current-key\")\n+    other = _session(session_key=\"unrelated-key\")\n+    monkeypatch.setattr(server, \"_sessions\", {\"origin-sid\": mine, \"other-sid\": other})\n+    monkeypatch.setattr(server, \"_get_db\", lambda: None)\n+    evt = {\n+        \"type\": \"async_delegation\",\n+        \"session_key\": \"stale-or-rotated-key\",\n+        \"origin_ui_session_id\": \"origin-sid\",\n+    }\n+\n+    assert server._notification_event_belongs_elsewhere(\"other-sid\", other, evt) is True\n+    assert server._notification_event_belongs_elsewhere(\"origin-sid\", mine, evt) is False\n+\n+\n+def test_notification_event_follows_compression_continuation(monkeypatch):\n+    \"\"\"Events keyed to a compressed parent route to the live continuation.\"\"\"\n+    old_parent = _session(session_key=\"old-parent\")\n+    live_tip = _session(session_key=\"new-tip\")\n+    monkeypatch.setattr(server, \"_sessions\", {\"old-sid\": old_parent, \"tip-sid\": live_tip})\n+\n+    class _DB:\n+        def resolve_resume_session_id(self, session_id):\n+            return \"new-tip\" if session_id == \"old-parent\" else session_id\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    evt = {\"type\": \"async_delegation\", \"session_key\": \"old-parent\"}\n+\n+    assert server._notification_event_belongs_elsewhere(\"old-sid\", old_parent, evt) is True\n+    assert server._notification_event_belongs_elsewhere(\"tip-sid\", live_tip, evt) is False\n+    # A third session must leave it alone for the continuation's poller.\n+    third = _session(session_key=\"third\")\n+    monkeypatch.setattr(\n+        server,\n+        \"_sessions\",\n+        {\"old-sid\": old_parent, \"tip-sid\": live_tip, \"third-sid\": third},\n+    )\n+    assert server._notification_event_belongs_elsewhere(\"third-sid\", third, evt) is True\n+\n+\n+def test_finalized_origin_ui_session_falls_back_to_live_continuation(monkeypatch):\n+    \"\"\"A closed origin tab must not steal its resumed continuation's result.\"\"\"\n+    finalized_origin = _session(session_key=\"old-parent\", _finalized=True)\n+    live_tip = _session(session_key=\"new-tip\")\n+    monkeypatch.setattr(\n+        server,\n+        \"_sessions\",\n+        {\"origin-sid\": finalized_origin, \"tip-sid\": live_tip},\n+    )\n+\n+    class _DB:\n+        def resolve_resume_session_id(self, session_id):\n+            return \"new-tip\" if session_id == \"old-parent\" else session_id\n+\n+    monkeypatch.setattr(server, \"_get_db\", lambda: _DB())\n+    evt = {\n+        \"type\": \"async_delegation\",\n+        \"session_key\": \"old-parent\",\n+        \"origin_ui_session_id\": \"origin-sid\",\n+    }\n+\n+    assert server._notification_event_belongs_elsewhere(\"origin-sid\", finalized_origin, evt) is True\n+    assert server._notification_event_belongs_elsewhere(\"tip-sid\", live_tip, evt) is False\n \n \n def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch):"]},{"file":"tests/tools/test_async_delegation.py","hunks":["@@ -289,6 +289,68 @@ def slow_child(task_index, goal, child=None, parent_agent=None, **kw):\n     assert \"the real task\" in text\n \n \n+def test_delegate_task_background_uses_live_tui_agent_session_id(monkeypatch):\n+    \"\"\"TUI async delegation must route to the live/compressed agent id.\n+\n+    Regression: delegate_task captured the stale approval/session context key\n+    after compression rotated parent_agent.session_id. The resulting completion\n+    was orphaned and could be consumed by an unrelated desktop session poller.\n+    \"\"\"\n+    import json\n+    from unittest.mock import MagicMock\n+    import tools.delegate_tool as dt\n+    from gateway.session_context import clear_session_vars, set_session_vars\n+    from tools.approval import reset_current_session_key, set_current_session_key\n+\n+    parent = MagicMock()\n+    parent._delegate_depth = 0\n+    parent.session_id = \"post-compress-tip\"\n+    parent._interrupt_requested = False\n+    parent._active_children = []\n+    parent._active_children_lock = None\n+    fake_child = MagicMock()\n+    fake_child._delegate_role = \"leaf\"\n+\n+    creds = {\n+        \"model\": \"m\", \"provider\": None, \"base_url\": None, \"api_key\": None,\n+        \"api_mode\": None, \"command\": None, \"args\": None,\n+    }\n+    monkeypatch.setattr(dt, \"_build_child_agent\", lambda **kw: fake_child)\n+    monkeypatch.setattr(dt, \"_resolve_delegation_credentials\", lambda *a, **k: creds)\n+    monkeypatch.setattr(\n+        dt,\n+        \"_run_single_child\",\n+        lambda *a, **k: {\n+            \"task_index\": 0,\n+            \"status\": \"completed\",\n+            \"summary\": \"done\",\n+            \"api_calls\": 1,\n+            \"duration_seconds\": 0.1,\n+            \"model\": \"m\",\n+            \"exit_reason\": \"completed\",\n+        },\n+    )\n+\n+    approval_token = set_current_session_key(\"pre-compress-parent\")\n+    session_tokens = set_session_vars(\n+        source=\"tui\",\n+        session_key=\"pre-compress-parent\",\n+        ui_session_id=\"origin-tab\",\n+    )\n+    try:\n+        out = dt.delegate_task(goal=\"bg task\", background=True, parent_agent=parent)\n+        assert json.loads(out)[\"status\"] == \"dispatched\"\n+        evt = _drain_one()\n+    finally:\n+        reset_current_session_key(approval_token)\n+        clear_session_vars(session_tokens)\n+\n+    assert evt is not None\n+    assert evt[\"type\"] == \"async_delegation\"\n+    assert evt[\"session_key\"] == \"post-compress-tip\"\n+    assert evt[\"origin_ui_session_id\"] == \"origin-tab\"\n+\n+\n def test_delegate_task_background_batch_runs_as_one_unit(monkeypatch):\n     \"\"\"A multi-item batch with background=True dispatches the WHOLE fan-out as\n     ONE background unit (one handle, one async slot). The children run in"]},{"file":"tests/tui_gateway/test_delegation_session_lifecycle.py","hunks":["@@ -0,0 +1,158 @@\n+\"\"\"Fail-closed ownership + session-scoped delegation lifecycle (#55578).\n+\n+Covers the two hardening rules layered on top of the origin-routing salvage:\n+\n+1. ``_session_owns_notification_event`` — positive-proof ownership. An\n+   async-delegation completion may only be injected into a session that\n+   PROVABLY commissioned it (origin UI id, or session-key/lineage match).\n+   Orphans are never adopted by a foreign chat.\n+\n+2. ``interrupt_for_session`` — a session's in-flight async delegations end\n+   with the session. ``_finalize_session`` interrupts delegations owned by\n+   the closing session (by origin UI id always; by durable key only when the\n+   TUI owns the lifecycle).\n+\"\"\"\n+\n+import threading\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+import tools.async_delegation as ad\n+from tui_gateway.server import (\n+    _finalize_session,\n+    _session_owns_notification_event,\n+)\n+\n+\n[redacted:email](autouse=True)\n+def _reset_async_delegation():\n+    ad._reset_for_tests()\n+    yield\n+    ad._reset_for_tests()\n+\n+\n+class TestSessionOwnsNotificationEvent:\n+    def _session(self, key=\"sess_key_1\"):\n+        return {\"session_key\": key, \"_finalized\": False}\n+\n+    def test_origin_ui_match_owns(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"tab1\", \"session_key\": \"other\"}\n+        assert _session_owns_notification_event(\"tab1\", self._session(), evt) is True\n+\n+    def test_session_key_match_owns(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"\", \"session_key\": \"sess_key_1\"}\n+        assert _session_owns_notification_event(\"tabX\", self._session(\"sess_key_1\"), evt) is True\n+\n+    def test_orphan_is_not_owned(self):\n+        \"\"\"No origin match, no key match, owner gone → NOT ours (fail closed).\"\"\"\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"dead_tab\", \"session_key\": \"gone_key\"}\n+        assert _session_owns_notification_event(\"tab1\", self._session(), evt) is False\n+\n+    def test_empty_key_and_origin_not_owned(self):\n+        \"\"\"A delegation event with no return address at all is never adopted.\"\"\"\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"\", \"session_key\": \"\"}\n+        assert _session_owns_notification_event(\"tab1\", self._session(), evt) is False\n+\n+    def test_finalized_session_owns_nothing(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"tab1\", \"session_key\": \"sess_key_1\"}\n+        sess = self._session()\n+        sess[\"_finalized\"] = True\n+        assert _session_owns_notification_event(\"tab1\", sess, evt) is False\n+\n+    def test_compression_chain_resolution_owns(self):\n+        evt = {\"type\": \"async_delegation\", \"origin_ui_session_id\": \"\", \"session_key\": \"parent_key\"}\n+        db = MagicMock()\n+        db.resolve_resume_session_id.return_value = \"child_key\"\n+        with patch(\"tui_gateway.server._get_db\", return_value=db):\n+            assert _session_owns_notification_event(\"tabX\", self._session(\"child_key\"), evt) is True\n+\n+\n+class TestInterruptForSession:\n+    def _seed_record(self, delegation_id, session_key=\"\", origin_ui_session_id=\"\", status=\"running\"):\n+        fn = MagicMock()\n+        with ad._records_lock:\n+            ad._records[delegation_id] = {\n+                \"delegation_id\": delegation_id,\n+                \"status\": status,\n+                \"session_key\": session_key,\n+                \"origin_ui_session_id\": origin_ui_session_id,\n+                \"interrupt_fn\": fn,\n+            }\n+        return fn\n+\n+    def test_interrupts_only_matching_session(self):\n+        mine = self._seed_record(\"d1\", session_key=\"sess_A\")\n+        other = self._seed_record(\"d2\", session_key=\"sess_B\")\n+        n = ad.interrupt_for_session(session_key=\"sess_A\")\n+        assert n == 1\n+        mine.assert_called_once()\n+        other.assert_not_called()\n+\n+    def test_matches_by_origin_ui_session_id(self):\n+        mine = self._seed_record(\"d1\", origin_ui_session_id=\"tab1\")\n+        other = self._seed_record(\"d2\", origin_ui_session_id=\"tab2\")\n+        n = ad.interrupt_for_session(origin_ui_session_id=\"tab1\")\n+        assert n == 1\n+        mine.assert_called_once()\n+        other.assert_not_called()\n+\n+    def test_no_selector_is_noop(self):\n+        fn = self._seed_record(\"d1\", session_key=\"sess_A\")\n+        assert ad.interrupt_for_session() == 0\n+        fn.assert_not_called()\n+\n+    def test_completed_records_untouched(self):\n+        fn = self._seed_record(\"d1\", session_key=\"sess_A\", status=\"completed\")\n+        assert ad.interrupt_for_session(session_key=\"sess_A\") == 0\n+        fn.assert_not_called()\n+\n+\n+class TestFinalizeInterruptsOwnDelegations:\n+    def _make_session(self, session_key=\"sess_A\", sid=\"tab1\"):\n+        agent = MagicMock()\n+        agent.session_id = session_key\n+        agent._session_messages = None\n+        agent.model = \"m\"\n+        agent.platform = \"tui\"\n+        return {\n+            \"agent\": agent,\n+            \"history\": [{\"role\": \"user\", \"content\": \"x\"}],\n+            \"history_lock\": threading.Lock(),\n+            \"session_key\": session_key,\n+            \"_finalized\": False,\n+            \"_sid\": sid,\n+        }\n+\n+    @patch(\"tui_gateway.server._get_db\")\n+    def test_finalize_interrupts_sessions_delegations(self, mock_get_db):\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"source\": \"tui\"}\n+        mock_get_db.return_value = mock_db\n+\n+        with patch(\"tools.async_delegation.interrupt_for_session\") as mock_int:\n+            _finalize_session(self._make_session(), end_reason=\"tui_close\")\n+\n+        mock_int.assert_called_once()\n+        kwargs = mock_int.call_args.kwargs\n+        assert kwargs[\"session_key\"] == \"sess_A\"\n+        assert kwargs[\"origin_ui_session_id\"] == \"tab1\"\n+\n+    @patch(\"tui_gateway.server._get_db\")\n+    def test_viewer_of_gateway_session_only_interrupts_by_origin(self, mock_get_db):\n+        \"\"\"Closing a TUI viewer tab on a live gateway session must not kill\n+        the gateway's own background work — key-based interrupt is skipped,\n+        origin-id interrupt (this tab's own dispatches) still applies.\"\"\"\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"source\": \"telegram\"}\n+        mock_get_db.return_value = mock_db\n+\n+        with patch(\"tools.async_delegation.interrupt_for_session\") as mock_int:\n+            _finalize_session(\n+                self._make_session(session_key=\"agent:main:telegram:dm:123\", sid=\"tab9\"),\n+                end_reason=\"ws_orphan_reap\",\n+            )\n+\n+        kwargs = mock_int.call_args.kwargs\n+        assert kwargs[\"session_key\"] == \"\"\n+        assert kwargs[\"origin_ui_session_id\"] == \"tab9\""]},{"file":"tools/async_delegation.py","hunks":["@@ -130,6 +130,7 @@ def dispatch_async_delegation(\n     model: Optional[str],\n     session_key: str,\n     runner: Callable[[], Dict[str, Any]],\n+    origin_ui_session_id: str = \"\",\n     interrupt_fn: Optional[Callable[[], None]] = None,\n     max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,\n ) -> Dict[str, Any]:","@@ -172,6 +173,7 @@ def dispatch_async_delegation(\n         \"role\": role,\n         \"model\": model,\n         \"session_key\": session_key,\n+        \"origin_ui_session_id\": origin_ui_session_id,\n         \"status\": \"running\",\n         \"dispatched_at\": dispatched_at,\n         \"completed_at\": None,","@@ -282,6 +284,7 @@ def _push_completion_event(\n         # session_key routes the completion back to the originating gateway\n         # session; empty string => CLI (single-session) path.\n         \"session_key\": record.get(\"session_key\", \"\"),\n+        \"origin_ui_session_id\": record.get(\"origin_ui_session_id\", \"\"),\n         \"goal\": record.get(\"goal\", \"\"),\n         \"context\": record.get(\"context\"),\n         \"toolsets\": record.get(\"toolsets\"),","@@ -317,6 +320,7 @@ def dispatch_async_delegation_batch(\n     model: Optional[str],\n     session_key: str,\n     runner: Callable[[], Dict[str, Any]],\n+    origin_ui_session_id: str = \"\",\n     interrupt_fn: Optional[Callable[[], None]] = None,\n     max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN,\n ) -> Dict[str, Any]:","@@ -356,6 +360,7 @@ def dispatch_async_delegation_batch(\n         \"role\": role,\n         \"model\": model,\n         \"session_key\": session_key,\n+        \"origin_ui_session_id\": origin_ui_session_id,\n         \"status\": \"running\",\n         \"dispatched_at\": dispatched_at,\n         \"completed_at\": None,","@@ -453,6 +458,7 @@ def _finalize_batch(\n         \"type\": \"async_delegation\",\n         \"delegation_id\": delegation_id,\n         \"session_key\": event_record.get(\"session_key\", \"\"),\n+        \"origin_ui_session_id\": event_record.get(\"origin_ui_session_id\", \"\"),\n         \"goal\": event_record.get(\"goal\", \"\"),\n         \"goals\": event_record.get(\"goals\"),\n         \"context\": event_record.get(\"context\"),","@@ -519,6 +525,54 @@ def interrupt_all(reason: str = \"shutdown\") -> int:\n     return count\n \n \n+def interrupt_for_session(\n+    session_key: str = \"\",\n+    origin_ui_session_id: str = \"\",\n+    reason: str = \"session_end\",\n+) -> int:\n+    \"\"\"Signal running async delegations owned by ONE session to stop.\n+\n+    A delegation's lifecycle is bound to the session that spawned it: when\n+    that session ends, its in-flight background subagents must end with it —\n+    a completed orphan would otherwise sit on the shared completion queue\n+    with no live owner, either leaking into another chat or burning tokens\n+    with no one listening (#55578).\n+\n+    Matches on ``origin_ui_session_id`` (the live UI session that\n+    commissioned the work) and/or the durable ``session_key``; either\n+    matching field claims the record. Returns how many were interrupted.\n+    \"\"\"\n+    if not session_key and not origin_ui_session_id:\n+        return 0\n+    count = 0\n+    with _records_lock:\n+        targets = [\n+            r for r in _records.values()\n+            if r.get(\"status\") == \"running\"\n+            and (\n+                (origin_ui_session_id and str(r.get(\"origin_ui_session_id\") or \"\") == origin_ui_session_id)\n+                or (session_key and str(r.get(\"session_key\") or \"\") == session_key)\n+            )\n+        ]\n+    for r in targets:\n+        fn = r.get(\"interrupt_fn\")\n+        if callable(fn):\n+            try:\n+                fn()\n+                count += 1\n+            except Exception as exc:\n+                logger.debug(\n+                    \"interrupt_for_session: %s interrupt failed: %s\",\n+                    r.get(\"delegation_id\"), exc,\n+                )\n+    if count:\n+        logger.info(\n+            \"Interrupted %d async delegation(s) for ending session (%s)\",\n+            count, reason,\n+        )\n+    return count\n+\n+\n def _reset_for_tests() -> None:\n     \"\"\"Test-only: clear all state and tear down the executor.\"\"\"\n     global _executor, _executor_max_workers"]},{"file":"tools/delegate_tool.py","hunks":["@@ -2796,6 +2796,25 @@ def _execute_and_aggregate() -> dict:\n             return json.dumps(_sync_result, ensure_ascii=False)\n \n         _session_key = get_current_session_key(default=\"\")\n+        _origin_ui_session_id = \"\"\n+        try:\n+            from gateway.session_context import get_session_env\n+\n+            _source = get_session_env(\"HERMES_SESSION_SOURCE\", \"\")\n+            _origin_ui_session_id = get_session_env(\"HERMES_UI_SESSION_ID\", \"\")\n+            # In desktop/TUI, the routable session key is the durable\n+            # AIAgent.session_id. Context compression can rotate that id during\n+            # the same turn before the TUI-side session dict is re-anchored;\n+            # if we capture the stale approval/session context key here, the\n+            # async completion becomes an orphan and any desktop poller may\n+            # consume it. Gateway chats are different: their session_key is the\n+            # platform conversation key (agent:main:...), so keep it there.\n+            if _source == \"tui\":\n+                _agent_session_id = str(getattr(parent_agent, \"session_id\", \"\") or \"\")\n+                if _agent_session_id:\n+                    _session_key = _agent_session_id\n+        except Exception:\n+            _origin_ui_session_id = \"\"\n         _child_agents = [c for (_, _, c) in children]\n \n         # Detach every child from the parent's interrupt-propagation list — the","@@ -2836,6 +2855,7 @@ def _batch_interrupt():\n             role=top_role,\n             model=creds[\"model\"],\n             session_key=_session_key,\n+            origin_ui_session_id=_origin_ui_session_id,\n             runner=_batch_runner,\n             interrupt_fn=_batch_interrupt,\n             max_async_children=_get_max_async_children(),"]},{"file":"tui_gateway/server.py","hunks":["@@ -625,6 +625,7 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n     # Use session_id (from agent.session_id) not session_key — after compression,\n     # session_key may be stale (the ended parent) while session_id is the live\n     # continuation. Fix for #20001.\n+    _tui_owns_lifecycle = True\n     if session_id:\n         try:\n             db = _get_db()","@@ -638,11 +639,40 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n                 # repeats on every inbound message.  (#60609)\n                 row = db.get_session(session_id)\n                 source = (row or {}).get(\"source\", \"\")\n-                if not _is_gateway_owned_source(source):\n+                _tui_owns_lifecycle = not _is_gateway_owned_source(source)\n+                if _tui_owns_lifecycle:\n                     db.end_session(session_id, end_reason)\n         except Exception:\n             pass\n \n+    # A session's in-flight async delegations end WITH the session (#55578):\n+    # once nobody owns the return address, a still-running background subagent\n+    # can only burn tokens and park an orphaned completion on the shared\n+    # queue. Always interrupt delegations commissioned by THIS live UI session\n+    # (its sid); additionally interrupt by durable session_key, but only when\n+    # the TUI owns the lifecycle — closing a viewer tab on a live gateway\n+    # session must not kill the gateway's own background work.\n+    try:\n+        from tools.async_delegation import interrupt_for_session\n+\n+        _own_sid = str(session.get(\"_sid\") or \"\")\n+        if not _own_sid:\n+            try:\n+                with _sessions_lock:\n+                    for _cand_sid, _cand in _sessions.items():\n+                        if _cand is session:\n+                            _own_sid = _cand_sid\n+                            break\n+            except Exception:\n+                _own_sid = \"\"\n+        interrupt_for_session(\n+            session_key=str(session_key or \"\") if _tui_owns_lifecycle else \"\",\n+            origin_ui_session_id=_own_sid,\n+            reason=end_reason,\n+        )\n+    except Exception:\n+        pass\n+\n     # Close the slash-worker subprocess as part of finalize itself, not just\n     # in the callers. Defense-in-depth: every session-end path goes through\n     # _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:\n         session = _sessions.pop(sid, None)\n     if session is None:\n         return False\n+    # The session is already out of _sessions here, so downstream teardown\n+    # (e.g. _finalize_session's per-session async-delegation interrupt) can't\n+    # recover its live id by scanning the dict — stamp it on the record.\n+    session[\"_sid\"] = sid\n     _teardown_session(session, end_reason=end_reason)\n     return True\n ","@@ -1946,7 +1980,12 @@ def _cwd_for_session_key(session_key: str) -> str:\n     return \"\"\n \n \n-def _set_session_context(session_key: str, cwd: str | None = None) -> list:\n+def _set_session_context(\n+    session_key: str,\n+    cwd: str | None = None,\n+    *,\n+    ui_session_id: str = \"\",\n+) -> list:\n     try:\n         from gateway.session_context import set_session_vars\n ","@@ -1961,7 +2000,12 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list:\n                 if sess.get(\"session_key\") == session_key:\n                     source = _session_source(sess)\n                     break\n-        return set_session_vars(session_key=session_key, source=source, cwd=resolved)\n+        return set_session_vars(\n+            session_key=session_key,\n+            source=source,\n+            cwd=resolved,\n+            ui_session_id=ui_session_id,\n+        )\n     except Exception:\n         return []\n ","@@ -8451,22 +8495,76 @@ def run_after_agent_ready() -> None:\n     return _ok(rid, {\"status\": \"streaming\"})\n \n \n-def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:\n+def _notification_event_belongs_elsewhere(sid: str, session: dict, evt: dict) -> bool:\n     \"\"\"True if ``evt`` is owned by a *different* live session.\n \n-    Background-process events carry the ``session_key`` of the session that\n-    started the process. Since all desktop sessions share one process-wide\n-    completion queue, each poller must skip events it doesn't own so a\n-    background job's completion surfaces in the session that launched it — not\n-    whichever poller happened to dequeue first. Orphaned events (owner gone)\n-    and global/system events (empty ``session_key``) return False so the\n-    current poller still handles them rather than losing them.\n+    Background completions carry the ``session_key`` of the session that started\n+    the work. Async delegation completions from the desktop also carry\n+    ``origin_ui_session_id``: the live TUI tab/window that commissioned them.\n+    Since all desktop sessions share one process-wide completion queue, each\n+    poller must skip events it doesn't own so a detached result surfaces in the\n+    launching session, not whichever poller happened to dequeue first.\n     \"\"\"\n+    evt_ui_sid = str(evt.get(\"origin_ui_session_id\") or \"\")\n+    if evt_ui_sid:\n+        if evt_ui_sid == str(sid or \"\") and not session.get(\"_finalized\"):\n+            return False\n+        try:\n+            with _sessions_lock:\n+                owner_live = evt_ui_sid in _sessions and not _sessions[evt_ui_sid].get(\"_finalized\")\n+        except Exception:\n+            owner_live = False\n+        if owner_live:\n+            return True\n+        # If the exact UI tab is gone, fall through to durable session_key\n+        # routing. That avoids wrong-session delivery while still allowing a\n+        # resumed continuation with the same durable key/lineage to claim it.\n+\n     evt_key = str(evt.get(\"session_key\") or \"\")\n     if not evt_key:\n         return False\n-    if evt_key == str(session.get(\"session_key\") or \"\"):\n+\n+    current_keys = {\n+        str(session.get(\"session_key\") or \"\"),\n+        _session_lookup_key(session, fallback=sid),\n+    }\n+\n+    # Compression can rotate AIAgent.session_id while the detached child is\n+    # still running. Resolve the event's original key to its continuation tip so\n+    # an event captured before or after compression still maps to the same live\n+    # desktop session instead of becoming an orphan that any poller may consume.\n+    resolved_key = evt_key\n+    try:\n+        db = _get_db()\n+        if db is not None:\n+            resolved_key = db.resolve_resume_session_id(evt_key) or evt_key\n+    except Exception:\n+        resolved_key = evt_key\n+\n+    # If the key has a live continuation, prefer that continuation over the\n+    # compressed parent. Otherwise a stale parent tab could consume the event\n+    # before the real current conversation sees it.\n+    if resolved_key != evt_key:\n+        if resolved_key in current_keys:\n+            return False\n+        try:\n+            with _sessions_lock:\n+                continuation_live = any(\n+                    not s.get(\"_finalized\")\n+                    and (\n+                        str(s.get(\"session_key\") or \"\") == resolved_key\n+                        or _session_lookup_key(s, fallback=\"\") == resolved_key\n+                    )\n+                    for s in _sessions.values()\n+                )\n+        except Exception:\n+            continuation_live = False\n+        if continuation_live:\n+            return True\n+\n+    if evt_key in current_keys:\n         return False\n+\n     try:\n         with _sessions_lock:\n             snapshot = list(_sessions.values())","@@ -8476,11 +8574,50 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:\n         return False\n \n     return any(\n-        s is not session and str(s.get(\"session_key\") or \"\") == evt_key\n+        s is not session\n+        and not s.get(\"_finalized\")\n+        and (\n+            str(s.get(\"session_key\") or \"\") in {evt_key, resolved_key}\n+            or _session_lookup_key(s, fallback=\"\") in {evt_key, resolved_key}\n+        )\n         for s in snapshot\n     )\n \n \n+def _session_owns_notification_event(sid: str, session: dict, evt: dict) -> bool:\n+    \"\"\"True iff *this* session PROVABLY owns ``evt``.\n+\n+    Positive ownership — the mirror of ``_notification_event_belongs_elsewhere``\n+    minus its orphan-adoption fallback. An event owns-matches when its\n+    ``origin_ui_session_id`` is this live session, or its ``session_key``\n+    (raw or resolved through the compression chain) matches this session's\n+    key/lineage. Used as a fail-closed gate for async-delegation payloads:\n+    \"not provably elsewhere\" is NOT good enough to inject a conversation\n+    payload into this chat (#55578).\n+    \"\"\"\n+    if session.get(\"_finalized\"):\n+        return False\n+    if str(evt.get(\"origin_ui_session_id\") or \"\") == str(sid or \"\"):\n+        return True\n+    evt_key = str(evt.get(\"session_key\") or \"\")\n+    if not evt_key:\n+        return False\n+    current_keys = {\n+        str(session.get(\"session_key\") or \"\"),\n+        _session_lookup_key(session, fallback=sid),\n+    }\n+    if evt_key in current_keys:\n+        return True\n+    try:\n+        db = _get_db()\n+        resolved_key = (\n+            db.resolve_resume_session_id(evt_key) if db is not None else evt_key\n+        ) or evt_key\n+    except Exception:\n+        resolved_key = evt_key\n+    return resolved_key in current_keys\n+\n+\n def _notification_event_dedup_key(evt: dict) -> tuple:\n     \"\"\"Return the UI-emission identity for a process notification event.\n ","@@ -8546,11 +8683,37 @@ def _notification_poller_loop(\n         # process started in session A would surface its completion in whichever\n         # session's poller happened to wake first (Ben's \"reported in a\n         # different session\" bug). Leave foreign events for their owner.\n-        if _notification_event_belongs_elsewhere(session, evt):\n+        if _notification_event_belongs_elsewhere(sid, session, evt):\n             process_registry.completion_queue.put(evt)\n             time.sleep(0.1)\n             continue\n \n+        # Fail closed for async-delegation results (#55578): these carry a\n+        # conversation payload, and injecting one into any chat other than the\n+        # one that commissioned it is a hard cross-session leak. The\n+        # belongs-elsewhere check above already re-queued events owned by\n+        # another LIVE session; what reaches here is either ours or an\n+        # orphan whose owner is gone. Orphaned delegation payloads are\n+        # DROPPED, not adopted — the subagent's summary is already persisted\n+        # in the delegation records/output store, so nothing is lost, whereas\n+        # a wrong-chat injection is unrecoverable. Non-delegation events\n+        # (background process completions etc.) keep the historical\n+        # adopt-orphans behavior.\n+        if evt.get(\"type\") == \"async_delegation\" and not _session_owns_notification_event(\n+            sid, session, evt\n+        ):\n+            logger.warning(\n+                \"async-delegation completion %s has no live owner \"\n+                \"(origin=%r key=%r); dropping from injection instead of \"\n+                \"delivering to session %s (#55578 fail-closed; result \"\n+                \"remains in the delegation records)\",\n+                evt.get(\"delegation_id\", \"?\"),\n+                str(evt.get(\"origin_ui_session_id\") or \"\"),\n+                str(evt.get(\"session_key\") or \"\"),\n+                sid,\n+            )\n+            continue\n+\n         _evt_sid = evt.get(\"session_id\", \"\")\n         if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n             continue","@@ -8604,7 +8767,16 @@ def _notification_poller_loop(\n             evt = process_registry.completion_queue.get_nowait()\n         except Exception:\n             break\n-        if _notification_event_belongs_elsewhere(session, evt):\n+        if _notification_event_belongs_elsewhere(sid, session, evt):\n+            deferred.append(evt)\n+            continue\n+        # Same fail-closed rule as the live loop: an orphaned async-delegation\n+        # payload is never adopted by a foreign session — defer it (a later\n+        # resume of the owner's lineage can still claim it) rather than\n+        # injecting another chat's conversation here (#55578).\n+        if evt.get(\"type\") == \"async_delegation\" and not _session_owns_notification_event(\n+            sid, session, evt\n+        ):\n             deferred.append(evt)\n             continue\n         _evt_sid = evt.get(\"session_id\", \"\")","@@ -8729,7 +8901,10 @@ def run():\n             )\n \n             approval_token = [redacted:secret-assignment]\"session_key\"])\n-            session_tokens = [redacted:secret-assignment]\"session_key\"])\n+            session_tokens = _set_session_context(\n+                session[\"session_key\"],\n+                ui_session_id=sid,\n+            )\n             _profile_home_str = session.get(\"profile_home\")\n             if _profile_home_str:\n                 home_token = [redacted:secret-assignment]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}