{"repo":"NousResearch/hermes-agent","public_id":"10760","display_label":"notify_on_complete broken on API server path","summary":"A cluster of bugs and fixes addressing notify_on_complete silently failing or being incorrectly handled when requests go through the API server or WebUI/Dashboard path, including missing session context, false async delivery promises, and recovery mechanisms for async results.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50319},"resolution":{"state":"resolved","by":"pr-50319"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T19:15:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":10760,"pr_number":50319,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":10760,"title":"Bug: notify_on_complete silently fails on API Server (WebUI/Dashboard) path","url":null,"state":"closed"},{"kind":"pr","number":10914,"title":"Fix API server notify_on_complete session context","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50319","number":50319},{"kind":"pr","value":"#10760","number":10760},{"kind":"pr","value":"#50319","number":50319}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19402,"title":"fix(gateway): set session contextvars in api_server _run_agent so notify_on_complete works","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10760","number":10760},{"kind":"pr","value":"#50319","number":50319},{"kind":"pr","value":"#50319","number":50319}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27044,"title":"fix(api-server): restore notify_on_complete routing for background terminal tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50319","number":50319},{"kind":"pr","value":"#10760","number":10760},{"kind":"pr","value":"#50319","number":50319}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50319,"title":"fix(api-server): stop silently promising async delivery on stateless HTTP path","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","gateway/platforms/base.py","gateway/run.py","gateway/session_context.py","tests/gateway/test_async_delivery_capability.py","tools/delegate_tool.py","tools/terminal_tool.py"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -749,6 +749,16 @@ class APIServerAdapter(BasePlatformAdapter):\n     and routes them through hermes-agent's AIAgent.\n     \"\"\"\n \n+    # Stateless request/response: every route (the OpenAI-spec\n+    # /v1/chat/completions and /v1/responses, and the proprietary /v1/runs SSE\n+    # stream) tears down its channel when the turn ends. There is no persistent\n+    # outbound channel to push a background completion to a client that already\n+    # received its response, and ``send()`` is a no-op stub. So async-delivery\n+    # tools (terminal notify_on_complete / watch_patterns, delegate_task\n+    # background=True) must NOT promise delivery on this path — see\n+    # ``async_delivery_supported()``.\n+    supports_async_delivery: bool = False\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.API_SERVER)\n         extra = config.extra or {}","@@ -3655,6 +3665,38 @@ def _concurrency_limited_response(self) -> Optional[\"web.Response\"]:\n             )\n         return None\n \n+    @staticmethod\n+    def _bind_api_server_session(\n+        *,\n+        chat_id: str = \"\",\n+        session_key: str = \"\",\n+        session_id: str = \"\",\n+    ) -> list:\n+        \"\"\"Bind session contextvars for an API-server agent run.\n+\n+        This is the SINGLE structural chokepoint every API-server agent-entry\n+        path must use to seed session context — it hardwires\n+        ``platform=\"api_server\"`` and ``async_delivery=False`` so a new route\n+        physically cannot reintroduce the silent-no-op bug (#10760) by\n+        forgetting to mark the channel as non-delivering. There is no\n+        ``async_delivery`` parameter to get wrong; the stateless HTTP path can\n+        never wake the agent after the turn ends, on ANY route.\n+\n+        Returns reset tokens; pass them to ``clear_session_vars`` in a\n+        ``finally`` block (the binding is request-scoped and must not outlive\n+        the turn — a session resumed later on a delivering interface, e.g. the\n+        CLI or a gateway platform, re-binds fresh and is NOT blocked).\n+        \"\"\"\n+        from gateway.session_context import set_session_vars\n+\n+        return set_session_vars(\n+            platform=\"api_server\",\n+            chat_id=chat_id,\n+            session_key=session_key,\n+            session_id=session_id,\n+            async_delivery=False,\n+        )\n+\n     async def _run_agent(\n         self,\n         user_message: str,","@@ -3682,10 +3724,9 @@ async def _run_agent(\n         loop = asyncio.get_running_loop()\n \n         def _run():\n-            from gateway.session_context import clear_session_vars, set_session_vars\n+            from gateway.session_context import clear_session_vars\n \n-            tokens = [redacted:secret-assignment]\n-                platform=\"api_server\",\n+            tokens = [redacted:secret-assignment]\n                 chat_id=session_id or \"\",\n                 session_key=gateway_session_key or session_id or \"\",\n                 session_id=session_id or \"\",","@@ -3940,7 +3981,7 @@ def _approval_notify(approval_data: Dict[str, Any]) -> None:\n                         pass\n \n                 def _run_sync():\n-                    from gateway.session_context import clear_session_vars, set_session_vars\n+                    from gateway.session_context import clear_session_vars\n                     from tools.approval import (\n                         register_gateway_notify,\n                         reset_current_session_key,","@@ -3956,8 +3997,7 @@ def _run_sync():\n                         # contextvars so concurrent runs do not share process\n                         # environment state.\n                         approval_token = [redacted:secret-assignment]\n-                        session_tokens = [redacted:secret-assignment]\n-                            platform=\"api_server\",\n+                        session_tokens = [redacted:secret-assignment]\n                             session_key=approval_session_key,\n                         )\n                         register_gateway_notify(approval_session_key, _approval_notify)"]},{"file":"gateway/platforms/base.py","hunks":["@@ -1925,6 +1925,22 @@ class BasePlatformAdapter(ABC):\n     # preview (see gateway/run.py progress_callback).\n     supports_code_blocks: bool = False\n \n+    # Whether this adapter can deliver an ASYNC notification back to the agent\n+    # AFTER a turn ends — i.e. wake a fresh turn to surface a background\n+    # process completion (terminal notify_on_complete / watch_patterns) or a\n+    # detached subagent result (delegate_task background=True).\n+    #\n+    # True for adapters that hold a persistent outbound channel (Telegram,\n+    # Discord, Slack, ... — they have a real ``send()`` and the gateway runs\n+    # the watcher/drain loops). False for stateless request/response adapters\n+    # (the API server): every route closes its channel when the turn ends, so\n+    # there is nowhere to push a later completion. The gateway propagates this\n+    # into the ``HERMES_SESSION_ASYNC_DELIVERY`` contextvar at session-bind\n+    # time; tools read it via ``async_delivery_supported()`` and refuse to make\n+    # a delivery promise they can't keep. A new stateless adapter only needs to\n+    # set this to False to stay correct-by-default.\n+    supports_async_delivery: bool = True\n+\n     # The command prefix users can always TYPE on this platform to reach\n     # Hermes commands.  Default \"/\" (most platforms deliver \"/approve\" etc.\n     # as plain message text).  Platforms where typing a leading \"/\" is"]},{"file":"gateway/run.py","hunks":["@@ -12683,6 +12683,16 @@ def _set_session_env(self, context: SessionContext) -> list:\n         in a ``finally`` block.\n         \"\"\"\n         from gateway.session_context import set_session_vars\n+        # Propagate the adapter's async-delivery capability so async tools\n+        # (terminal notify_on_complete / watch_patterns, delegate_task\n+        # background=True) know whether this channel can wake a later turn.\n+        # Default True keeps CLI / unknown paths working; stateless adapters\n+        # (api_server) declare supports_async_delivery=False. Use getattr so\n+        # bare runners built via object.__new__ (tests) without self.adapters\n+        # don't blow up — they simply default to supported.\n+        _adapters = getattr(self, \"adapters\", None) or {}\n+        _adapter = _adapters.get(context.source.platform)\n+        _async_delivery = getattr(_adapter, \"supports_async_delivery\", True)\n         return set_session_vars(\n             platform=context.source.platform.value,\n             chat_id=context.source.chat_id,","@@ -12692,6 +12702,7 @@ def _set_session_env(self, context: SessionContext) -> list:\n             user_name=str(context.source.user_name) if context.source.user_name else \"\",\n             session_key=context.session_key,\n             message_id=str(context.source.message_id) if context.source.message_id else \"\",\n+            async_delivery=_async_delivery,\n         )\n \n     def _clear_session_env(self, tokens: list) -> None:"]},{"file":"gateway/session_context.py","hunks":["@@ -62,6 +62,27 @@\n # private-chat topic (those lanes route only with thread id + reply anchor).\n _SESSION_MESSAGE_ID: ContextVar = ContextVar(\"HERMES_SESSION_MESSAGE_ID\", default=_UNSET)\n \n+# Whether the current session's delivery channel can route an ASYNC completion\n+# back to the agent AFTER the current turn ends (i.e. wake a fresh turn).\n+#\n+# True  — CLI (in-process completion_queue drain) and the real gateway\n+#         platforms (Telegram/Discord/Slack/...), which hold a persistent\n+#         outbound channel and run the watcher/drain loops.\n+# False — stateless request/response adapters (the API server: every route,\n+#         spec and proprietary, tears down its channel when the turn ends, so\n+#         a background completion that finishes later has nowhere to go).\n+#\n+# Tools that promise async delivery (terminal notify_on_complete /\n+# watch_patterns, delegate_task background=True) read this via\n+# ``async_delivery_supported()`` and refuse to hand out a promise the channel\n+# can't keep — turning a silent no-op into an explicit contract.\n+#\n+# Default _UNSET => treated as supported, so CLI (which never sets a platform)\n+# and any contextvar-unaware path keep working. Stateless adapters opt OUT by\n+# setting ``supports_async_delivery = False`` on the adapter class; the gateway\n+# propagates that into this contextvar at session-bind time.\n+_SESSION_ASYNC_DELIVERY: ContextVar = ContextVar(\"HERMES_SESSION_ASYNC_DELIVERY\", default=_UNSET)\n+\n # Cron auto-delivery vars — set per-job in run_job() so concurrent jobs\n # don't clobber each other's delivery targets.\n _CRON_AUTO_DELIVER_PLATFORM: ContextVar = ContextVar(\"HERMES_CRON_AUTO_DELIVER_PLATFORM\", default=_UNSET)","@@ -112,6 +133,7 @@ def set_session_vars(\n     session_id: str = \"\",\n     message_id: str = \"\",\n     cwd: str = \"\",\n+    async_delivery: bool = True,\n ) -> list:\n     \"\"\"Set all session context variables and return reset tokens.\n ","@@ -122,6 +144,11 @@ def set_session_vars(\n     only for API compatibility.\n \n     ``cwd`` pins the logical working directory for this context.\n+\n+    ``async_delivery`` declares whether this session's channel can route a\n+    background completion back to the agent after the turn ends (see\n+    ``_SESSION_ASYNC_DELIVERY`` / ``async_delivery_supported``). Stateless\n+    request/response adapters (the API server) pass ``False``.\n     \"\"\"\n     tokens = [\n         _SESSION_PLATFORM.set(platform),","@@ -134,6 +161,7 @@ def set_session_vars(\n         _SESSION_KEY.set(session_key),\n         _SESSION_ID.set(session_id),\n         _SESSION_MESSAGE_ID.set(message_id),\n+        _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)),\n     ]\n     try:\n         from agent.runtime_cwd import set_session_cwd","@@ -168,6 +196,11 @@ def clear_session_vars(tokens: list) -> None:\n         _SESSION_MESSAGE_ID,\n     ):\n         var.set(\"\")\n+    # Reset async-delivery capability to the \"never set\" sentinel rather than a\n+    # falsy value: a cleared context should fall back to the default-supported\n+    # behavior (CLI / unaware paths), not be mistaken for an opted-out\n+    # stateless adapter.\n+    _SESSION_ASYNC_DELIVERY.set(_UNSET)\n     try:\n         from agent.runtime_cwd import clear_session_cwd\n ","@@ -200,3 +233,22 @@ def get_session_env(name: str, default: str = \"\") -> str:\n             return value\n     # Fall back to os.environ for CLI, cron, and test compatibility\n     return os.getenv(name, default)\n+\n+\n+def async_delivery_supported() -> bool:\n+    \"\"\"Whether the current session can deliver a background completion later.\n+\n+    Returns ``False`` only when the active session was explicitly bound by a\n+    stateless adapter (the API server) that cannot route a notification back to\n+    the agent after the turn ends. CLI, cron, and the real gateway platforms —\n+    and any path that never bound the contextvar — return ``True``.\n+\n+    Tools that promise async delivery (``terminal`` notify_on_complete /\n+    watch_patterns, ``delegate_task`` background=True) consult this before\n+    registering a watcher / dispatching a detached child, so they can refuse a\n+    promise the channel can't keep instead of silently no-op'ing.\n+    \"\"\"\n+    value = _SESSION_ASYNC_DELIVERY.get()\n+    if value is _UNSET:\n+        return True\n+    return bool(value)"]},{"file":"tests/gateway/test_async_delivery_capability.py","hunks":["@@ -0,0 +1,211 @@\n+\"\"\"Tests for the async-delivery capability gate (issue #10760).\n+\n+Stateless request/response adapters (the API server / WebUI path) cannot route\n+a background completion back to the agent after a turn ends — there is no\n+persistent channel and ``APIServerAdapter.send()`` is a no-op stub. So tools\n+that promise async delivery (``terminal`` notify_on_complete / watch_patterns,\n+``delegate_task`` background=True) must refuse the promise on that path instead\n+of silently registering a watcher that never fires.\n+\n+This is wired through:\n+  - ``BasePlatformAdapter.supports_async_delivery`` (default True)\n+  - ``APIServerAdapter.supports_async_delivery = False``\n+  - ``gateway.session_context._SESSION_ASYNC_DELIVERY`` contextvar +\n+    ``async_delivery_supported()`` helper, bound per-session.\n+\n+These are behavior/invariant tests (how the capability relates to the channel),\n+not snapshots of a current value.\n+\"\"\"\n+\n+import json\n+\n+import pytest\n+\n+from gateway.session_context import (\n+    async_delivery_supported,\n+    clear_session_vars,\n+    get_session_env,\n+    set_session_vars,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Capability helper\n+# ---------------------------------------------------------------------------\n+\n+class TestAsyncDeliverySupported:\n+    def test_default_unbound_is_supported(self):\n+        \"\"\"CLI / cron / unaware paths never bind the var -> supported.\"\"\"\n+        assert async_delivery_supported() is True\n+\n+    def test_set_true_is_supported(self):\n+        tokens = [redacted:secret-assignment]\n+            platform=\"telegram\",\n+            chat_id=\"123\",\n+            session_key=\"telegram:private:123\",\n+            async_delivery=True,\n+        )\n+        try:\n+            assert async_delivery_supported() is True\n+            # Platform metadata stays readable alongside the capability.\n+            assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"telegram\"\n+        finally:\n+            clear_session_vars(tokens)\n+\n+    def test_set_false_is_unsupported(self):\n+        tokens = [redacted:secret-assignment]\n+            platform=\"api_server\",\n+            chat_id=\"sess1\",\n+            session_key=\"sess1\",\n+            async_delivery=False,\n+        )\n+        try:\n+            assert async_delivery_supported() is False\n+            # Platform must still be readable for routing/diagnostics even\n+            # though delivery is unsupported.\n+            assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"api_server\"\n+        finally:\n+            clear_session_vars(tokens)\n+\n+    def test_omitted_arg_defaults_supported(self):\n+        \"\"\"Back-compat: callers that don't pass async_delivery stay supported.\"\"\"\n+        tokens = [redacted:secret-assignment]\"discord\", chat_id=\"9\")\n+        try:\n+            assert async_delivery_supported() is True\n+        finally:\n+            clear_session_vars(tokens)\n+\n+    def test_clear_resets_to_default_supported(self):\n+        \"\"\"A cleared context must fall back to default-supported, NOT be\n+        mistaken for an opted-out stateless adapter.\"\"\"\n+        tokens = [redacted:secret-assignment]\n+            platform=\"api_server\", session_key=\"s1\", async_delivery=False\n+        )\n+        assert async_delivery_supported() is False\n+        clear_session_vars(tokens)\n+        assert async_delivery_supported() is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Adapter capability flag\n+# ---------------------------------------------------------------------------\n+\n+class TestAdapterCapabilityFlag:\n+    def test_base_default_true(self):\n+        from gateway.platforms.base import BasePlatformAdapter\n+\n+        assert BasePlatformAdapter.supports_async_delivery is True\n+\n+    def test_api_server_false(self):\n+        from gateway.platforms.api_server import APIServerAdapter\n+\n+        assert APIServerAdapter.supports_async_delivery is False\n+\n+    def test_api_server_bind_chokepoint_hardwires_no_delivery(self):\n+        \"\"\"Every API-server agent-entry path binds through\n+        _bind_api_server_session, which hardwires async_delivery=False — a new\n+        route physically cannot reintroduce the silent no-op (#10760).\"\"\"\n+        from gateway.platforms.api_server import APIServerAdapter\n+        from gateway.session_context import clear_session_vars, get_session_env\n+\n+        tokens = [redacted:secret-assignment]\n+            chat_id=\"c1\", session_key=\"sk1\", session_id=\"sid1\"\n+        )\n+        try:\n+            assert async_delivery_supported() is False\n+            assert get_session_env(\"HERMES_SESSION_PLATFORM\") == \"api_server\"\n+        finally:\n+            clear_session_vars(tokens)\n+\n+    def test_api_server_binding_does_not_outlive_turn(self):\n+        \"\"\"The no-delivery decision is request-scoped, NOT stuck to the session.\n+        After clear, a session resumed on a delivering interface re-binds fresh\n+        and is NOT blocked.\"\"\"\n+        from gateway.platforms.api_server import APIServerAdapter\n+        from gateway.session_context import clear_session_vars\n+\n+        # Turn 1: same session over the API server -> blocked.\n+        tokens = [redacted:secret-assignment]\"shared-key\")\n+        assert async_delivery_supported() is False\n+        clear_session_vars(tokens)\n+\n+        # Turn 2: SAME session_key resumed on a delivering interface (CLI/gateway)\n+        # -> supported. The earlier False did not follow the session.\n+        tokens = [redacted:secret-assignment]\n+            platform=\"telegram\",\n+            session_key=\"shared-key\",\n+            async_delivery=True,\n+        )\n+        try:\n+            assert async_delivery_supported() is True\n+        finally:\n+            clear_session_vars(tokens)\n+\n+\n+# ---------------------------------------------------------------------------\n+# terminal_tool: refuses to register a watcher on unsupported sessions\n+# ---------------------------------------------------------------------------\n+\n+class TestTerminalNotifyGate:\n+    @pytest.fixture(autouse=True)\n+    def _clean_watchers(self):\n+        from tools.process_registry import process_registry\n+\n+        process_registry.pending_watchers = []\n+        yield\n+        process_registry.pending_watchers = []\n+\n+    def _run_bg(self, command):\n+        from tools.terminal_tool import terminal_tool\n+\n+        return json.loads(\n+            terminal_tool(command=command, background=True, notify_on_complete=True)\n+        )\n+\n+    def test_api_server_skips_watcher_and_notes(self):\n+        from tools.process_registry import process_registry\n+\n+        tokens = [redacted:secret-assignment]\n+            platform=\"api_server\", chat_id=\"s1\", session_key=\"s1\", async_delivery=False\n+        )\n+        try:\n+            d = self._run_bg(\"sleep 30 && echo DONE\")\n+        finally:\n+            clear_session_vars(tokens)\n+\n+        assert d.get(\"notify_on_complete\") is False\n+        assert d.get(\"notify_unsupported\"), \"must explain the limitation\"\n+        assert \"poll\" in d[\"notify_unsupported\"].lower()\n+        assert len(process_registry.pending_watchers) == 0\n+\n+    def test_gateway_registers_watcher(self):\n+        from tools.process_registry import process_registry\n+\n+        tokens = [redacted:secret-assignment]\n+            platform=\"telegram\",\n+            chat_id=\"123\",\n+            thread_id=\"7\",\n+            user_id=\"u1\",\n+            session_key=\"telegram:private:123\",\n+            async_delivery=True,\n+        )\n+        try:\n+            d = self._run_bg(\"sleep 30 && echo DONE\")\n+        finally:\n+            clear_session_vars(tokens)\n+\n+        assert d.get(\"notify_on_complete\") is True\n+        assert not d.get(\"notify_unsupported\")\n+        assert len(process_registry.pending_watchers) == 1\n+        assert process_registry.pending_watchers[0][\"platform\"] == \"telegram\"\n+\n+    def test_cli_stays_supported(self):\n+        \"\"\"CLI delivers via the in-process completion_queue: notify stays on,\n+        no false 'unsupported' note, and no pending_watcher (empty platform).\"\"\"\n+        from tools.process_registry import process_registry\n+\n+        d = self._run_bg(\"sleep 30 && echo DONE\")\n+        assert d.get(\"notify_on_complete\") is True\n+        assert not d.get(\"notify_unsupported\")\n+        # No platform bound -> no gateway watcher, but completion_queue still fires.\n+        assert len(process_registry.pending_watchers) == 0"]},{"file":"tools/delegate_tool.py","hunks":["@@ -2490,6 +2490,34 @@ def _execute_and_aggregate() -> dict:\n         from tools.async_delegation import dispatch_async_delegation_batch\n         from tools.approval import get_current_session_key\n \n+        # Stateless request/response sessions (the API server / WebUI path)\n+        # cannot route a detached subagent result back to the agent after the\n+        # turn ends — there is no persistent channel and the adapter's send()\n+        # is a no-op, so a background dispatch would silently never re-enter the\n+        # conversation (issue #10760). Fall back to SYNCHRONOUS execution: the\n+        # work still runs and its result returns in this same response, which is\n+        # strictly better than a handle that never resolves. Mirrors the\n+        # pool-at-capacity inline fallback below.\n+        try:\n+            from gateway.session_context import async_delivery_supported\n+            _async_ok = async_delivery_supported()\n+        except Exception:\n+            _async_ok = True\n+        if not _async_ok:\n+            logger.info(\n+                \"delegate_task: async delivery unsupported on this session \"\n+                \"(stateless HTTP API); running the batch synchronously instead.\"\n+            )\n+            _sync_result = _execute_and_aggregate()\n+            if isinstance(_sync_result, dict):\n+                _sync_result[\"note\"] = (\n+                    \"background=true is not available on this endpoint (stateless \"\n+                    \"HTTP API — no channel to deliver a detached subagent result \"\n+                    \"after the turn ends), so the subagent(s) ran SYNCHRONOUSLY and \"\n+                    \"the result is included above.\"\n+                )\n+            return json.dumps(_sync_result, ensure_ascii=False)\n+\n         _session_key = get_current_session_key(default=\"\")\n         _child_agents = [c for (_, _, c) in children]\n "]},{"file":"tools/terminal_tool.py","hunks":["@@ -2297,20 +2297,47 @@ def terminal_tool(\n                 # watch-pattern and completion notifications can be\n                 # routed back to the correct chat/thread.\n                 if background and (notify_on_complete or watch_patterns):\n-                    from gateway.session_context import get_session_env as _gse\n-                    _gw_platform = _gse(\"HERMES_SESSION_PLATFORM\", \"\")\n-                    if _gw_platform:\n-                        _gw_chat_id = _gse(\"HERMES_SESSION_CHAT_ID\", \"\")\n-                        _gw_thread_id = _gse(\"HERMES_SESSION_THREAD_ID\", \"\")\n-                        _gw_user_id = _gse(\"HERMES_SESSION_USER_ID\", \"\")\n-                        _gw_user_name = _gse(\"HERMES_SESSION_USER_NAME\", \"\")\n-                        _gw_message_id = _gse(\"HERMES_SESSION_MESSAGE_ID\", \"\")\n-                        proc_session.watcher_platform = _gw_platform\n-                        proc_session.watcher_chat_id = _gw_chat_id\n-                        proc_session.watcher_user_id = _gw_user_id\n-                        proc_session.watcher_user_name = _gw_user_name\n-                        proc_session.watcher_thread_id = _gw_thread_id\n-                        proc_session.watcher_message_id = _gw_message_id\n+                    from gateway.session_context import (\n+                        async_delivery_supported as _async_ok,\n+                        get_session_env as _gse,\n+                    )\n+\n+                    # Stateless request/response sessions (the API server /\n+                    # WebUI path) cannot route a completion back to the agent\n+                    # after the turn ends — there is no persistent channel and\n+                    # send() is a no-op. Registering a watcher there silently\n+                    # no-ops (issue #10760). Refuse the promise instead: drop\n+                    # the flags and tell the agent to poll.\n+                    if not _async_ok():\n+                        notify_on_complete = False\n+                        watch_patterns = None\n+                        result_data[\"notify_on_complete\"] = False\n+                        result_data[\"notify_unsupported\"] = (\n+                            \"notify_on_complete / watch_patterns are not available on \"\n+                            \"this endpoint (stateless HTTP API — no channel to deliver \"\n+                            \"an async completion after the turn ends). The process is \"\n+                            \"running in the background; retrieve its result with \"\n+                            \"process(action='poll') or process(action='wait').\"\n+                        )\n+                        logger.info(\n+                            \"background proc %s: async delivery unsupported on this \"\n+                            \"session; notify_on_complete/watch_patterns disabled\",\n+                            proc_session.id,\n+                        )\n+                    else:\n+                        _gw_platform = _gse(\"HERMES_SESSION_PLATFORM\", \"\")\n+                        if _gw_platform:\n+                            _gw_chat_id = _gse(\"HERMES_SESSION_CHAT_ID\", \"\")\n+                            _gw_thread_id = _gse(\"HERMES_SESSION_THREAD_ID\", \"\")\n+                            _gw_user_id = _gse(\"HERMES_SESSION_USER_ID\", \"\")\n+                            _gw_user_name = _gse(\"HERMES_SESSION_USER_NAME\", \"\")\n+                            _gw_message_id = _gse(\"HERMES_SESSION_MESSAGE_ID\", \"\")\n+                            proc_session.watcher_platform = _gw_platform\n+                            proc_session.watcher_chat_id = _gw_chat_id\n+                            proc_session.watcher_user_id = _gw_user_id\n+                            proc_session.watcher_user_name = _gw_user_name\n+                            proc_session.watcher_thread_id = _gw_thread_id\n+                            proc_session.watcher_message_id = _gw_message_id\n \n                 # Mutual exclusion: if both notify_on_complete and watch_patterns\n                 # are set, drop watch_patterns. The combination produces duplicate\n"]}]},{"kind":"pr","number":51577,"title":"fix(async_delegation): add recovery mechanism for WebUI async results","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d0e9a42ce","number":0},{"kind":"commit","value":"67f4e1b4a","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}