{"repo":"NousResearch/hermes-agent","public_id":"15248","display_label":"Background process completion notifications delivery issues","summary":"These issues and pull requests concern the implementation and fixing of notify_on_complete notifications for background processes, which were being silently dropped or not delivered in TUI and WebUI/gateway contexts. Work spans both feature addition and multiple fix attempts across different delivery paths.","subsystem":"gateway","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":26327},"resolution":{"state":"partially_resolved","by":"pr-26327"},"freshness":{"state":"verified","last_verified_at":"2026-05-15T14:01:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":26071,"pr_number":26327,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":15248,"title":"TUI: notify_on_complete silently drops — no notification delivered for background terminal processes","url":null,"state":"closed"},{"kind":"issue","number":26071,"title":"notify_on_complete notifications silently lost in TUI mode","url":null,"state":"closed"},{"kind":"pr","number":15329,"title":"fix(tui): deliver notify_on_complete process notifications to TUI sessions","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":21597,"title":"fix(tui_gateway): event-driven delivery for notify_on_complete + watch_patterns","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d5416284f11ccbc735c8357f0ab35ce5f683ccc3","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26084,"title":"fix(tui): drain background process completion notifications ","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26327","number":26327}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":26327,"title":"fix(tui): autonomous background process completion notifications (#26071)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/test_tui_gateway_server.py","tests/tools/test_process_registry.py","tools/process_registry.py","tui_gateway/server.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -1965,43 +1965,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:\n     return resolved\n \n \n-def _format_process_notification(evt: dict) -> \"str | None\":\n-    \"\"\"Format a process notification event into a [IMPORTANT: ...] message.\n \n-    Handles both completion events (notify_on_complete) and watch pattern\n-    match events from the unified completion_queue.\n-    \"\"\"\n-    evt_type = evt.get(\"type\", \"completion\")\n-    _sid = evt.get(\"session_id\", \"unknown\")\n-    _cmd = evt.get(\"command\", \"unknown\")\n-\n-    if evt_type == \"watch_disabled\":\n-        return f\"[IMPORTANT: {evt.get('message', '')}]\"\n-\n-    if evt_type == \"watch_match\":\n-        _pat = evt.get(\"pattern\", \"?\")\n-        _out = evt.get(\"output\", \"\")\n-        _sup = evt.get(\"suppressed\", 0)\n-        text = (\n-            f\"[IMPORTANT: Background process {_sid} matched \"\n-            f\"watch pattern \\\"{_pat}\\\".\\n\"\n-            f\"Command: {_cmd}\\n\"\n-            f\"Matched output:\\n{_out}\"\n-        )\n-        if _sup:\n-            text += f\"\\n({_sup} earlier matches were suppressed by rate limit)\"\n-        text += \"]\"\n-        return text\n-\n-    # Default: completion event\n-    _exit = evt.get(\"exit_code\", \"?\")\n-    _out = evt.get(\"output\", \"\")\n-    return (\n-        f\"[IMPORTANT: Background process {_sid} completed \"\n-        f\"(exit code {_exit}).\\n\"\n-        f\"Command: {_cmd}\\n\"\n-        f\"Output:\\n{_out}]\"\n-    )\n \n \n def _detect_file_drop(user_input: str) -> \"dict | None\":","@@ -13518,16 +13482,8 @@ def process_loop():\n                             # and watch pattern matches) while agent is idle.\n                             try:\n                                 from tools.process_registry import process_registry\n-                                if not process_registry.completion_queue.empty():\n-                                    evt = process_registry.completion_queue.get_nowait()\n-                                    # Skip if the agent already consumed this via wait/poll/log\n-                                    _evt_sid = evt.get(\"session_id\", \"\")\n-                                    if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n-                                        pass  # already delivered via tool result\n-                                    else:\n-                                        _synth = _format_process_notification(evt)\n-                                        if _synth:\n-                                            self._pending_input.put(_synth)\n+                                for _evt, _synth in process_registry.drain_notifications():\n+                                    self._pending_input.put(_synth)\n                             except Exception:\n                                 pass\n                         continue","@@ -13635,15 +13591,8 @@ def _restart_recording():\n                         # that arrived while the agent was running.\n                         try:\n                             from tools.process_registry import process_registry\n-                            while not process_registry.completion_queue.empty():\n-                                evt = process_registry.completion_queue.get_nowait()\n-                                # Skip if the agent already consumed this via wait/poll/log\n-                                _evt_sid = evt.get(\"session_id\", \"\")\n-                                if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n-                                    continue  # already delivered via tool result\n-                                _synth = _format_process_notification(evt)\n-                                if _synth:\n-                                    self._pending_input.put(_synth)\n+                            for _evt, _synth in process_registry.drain_notifications():\n+                                self._pending_input.put(_synth)\n                         except Exception:\n                             pass  # Non-fatal — don't break the main loop\n "]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -4649,3 +4649,158 @@ def test_config_show_displays_nested_max_turns(monkeypatch):\n     )\n \n     assert [\"Max Turns\", \"120\"] in agent_rows\n+\n+\n+def test_notification_poller_delivers_completion(monkeypatch):\n+    \"\"\"Poller picks up completion events and triggers agent turns.\"\"\"\n+    from tools.process_registry import process_registry\n+\n+    turns = []\n+    emitted = []\n+\n+    class _Agent:\n+        def run_conversation(self, prompt, conversation_history=None, stream_callback=None):\n+            turns.append(prompt)\n+            return {\n+                \"final_response\": \"ok\",\n+                \"messages\": [{\"role\": \"assistant\", \"content\": \"ok\"}],\n+            }\n+\n+    class _ImmediateThread:\n+        def __init__(self, target=None, daemon=None):\n+            self._target = target\n+        def start(self):\n+            self._target()\n+\n+    sess = _session(agent=_Agent())\n+    server._sessions[\"sid_poll\"] = sess\n+    monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: emitted.append(a))\n+    monkeypatch.setattr(server, \"make_stream_renderer\", lambda cols: None)\n+    monkeypatch.setattr(server, \"render_message\", lambda raw, cols: None)\n+\n+    # Clear queue\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+    process_registry._completion_consumed.discard(\"proc_poller_test\")\n+\n+    stop = threading.Event()\n+\n+    # Put event on queue, then immediately signal stop so the poller\n+    # runs exactly one iteration.\n+    process_registry.completion_queue.put({\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_poller_test\",\n+        \"command\": \"echo hello\",\n+        \"exit_code\": 0,\n+        \"output\": \"hello\",\n+    })\n+    stop.set()\n+\n+    try:\n+        server._notification_poller_loop(stop, \"sid_poll\", sess)\n+\n+        # Should have emitted a status.update with kind=process\n+        status_calls = [a for a in emitted if a[0] == \"status.update\"]\n+        assert len(status_calls) >= 1\n+        assert status_calls[0][2][\"kind\"] == \"process\"\n+\n+        # Should have triggered an agent turn\n+        assert len(turns) == 1\n+        assert \"[IMPORTANT: Background process proc_poller_test completed\" in turns[0]\n+    finally:\n+        server._sessions.pop(\"sid_poll\", None)\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def test_notification_poller_skips_consumed(monkeypatch):\n+    \"\"\"Already-consumed completions are not dispatched by the poller.\"\"\"\n+    from tools.process_registry import process_registry\n+\n+    turns = []\n+\n+    class _Agent:\n+        def run_conversation(self, prompt, conversation_history=None, stream_callback=None):\n+            turns.append(prompt)\n+            return {\"final_response\": \"ok\", \"messages\": []}\n+\n+    class _ImmediateThread:\n+        def __init__(self, target=None, daemon=None):\n+            self._target = target\n+        def start(self):\n+            self._target()\n+\n+    sess = _session(agent=_Agent())\n+    server._sessions[\"sid_skip\"] = sess\n+    monkeypatch.setattr(server.threading, \"Thread\", _ImmediateThread)\n+    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: None)\n+    monkeypatch.setattr(server, \"make_stream_renderer\", lambda cols: None)\n+    monkeypatch.setattr(server, \"render_message\", lambda raw, cols: None)\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    process_registry._completion_consumed.add(\"proc_already_done\")\n+    process_registry.completion_queue.put({\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_already_done\",\n+        \"command\": \"echo x\",\n+        \"exit_code\": 0,\n+        \"output\": \"x\",\n+    })\n+\n+    stop = threading.Event()\n+    stop.set()\n+\n+    try:\n+        server._notification_poller_loop(stop, \"sid_skip\", sess)\n+        assert len(turns) == 0\n+    finally:\n+        server._sessions.pop(\"sid_skip\", None)\n+        process_registry._completion_consumed.discard(\"proc_already_done\")\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def test_notification_poller_requeues_when_busy(monkeypatch):\n+    \"\"\"When the agent is busy, the poller requeues the event.\"\"\"\n+    from tools.process_registry import process_registry\n+\n+    emitted = []\n+\n+    sess = _session(running=True)  # agent is busy\n+    server._sessions[\"sid_busy\"] = sess\n+    monkeypatch.setattr(server, \"_emit\", lambda *a, **kw: emitted.append(a))\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+    process_registry._completion_consumed.discard(\"proc_busy_test\")\n+\n+    evt = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_busy_test\",\n+        \"command\": \"make build\",\n+        \"exit_code\": 0,\n+        \"output\": \"ok\",\n+    }\n+    process_registry.completion_queue.put(evt)\n+\n+    stop = threading.Event()\n+    stop.set()\n+\n+    try:\n+        server._notification_poller_loop(stop, \"sid_busy\", sess)\n+\n+        # Status update was emitted (user sees it)\n+        status_calls = [a for a in emitted if a[0] == \"status.update\"]\n+        assert len(status_calls) == 1\n+\n+        # Event was requeued (agent was busy, no turn triggered)\n+        assert not process_registry.completion_queue.empty()\n+        requeued = process_registry.completion_queue.get_nowait()\n+        assert requeued[\"session_id\"] == \"proc_busy_test\"\n+    finally:\n+        server._sessions.pop(\"sid_busy\", None)\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()"]},{"file":"tests/tools/test_process_registry.py","hunks":["@@ -865,3 +865,138 @@ def test_unknown_action(self):\n         from tools.process_registry import _handle_process\n         result = json.loads(_handle_process({\"action\": \"unknown_action\"}))\n         assert \"error\" in result\n+\n+\n+# =========================================================================\n+# format_process_notification + drain_notifications (shared helpers)\n+# =========================================================================\n+\n+from tools.process_registry import format_process_notification\n+\n+\n+def test_format_completion_event():\n+    evt = {\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_abc\",\n+        \"command\": \"sleep 5\",\n+        \"exit_code\": 0,\n+        \"output\": \"done\",\n+    }\n+    result = format_process_notification(evt)\n+    assert \"[IMPORTANT: Background process proc_abc completed\" in result\n+    assert \"exit code 0\" in result\n+    assert \"Command: sleep 5\" in result\n+    assert \"Output:\\ndone]\" in result\n+\n+\n+def test_format_watch_match_event():\n+    evt = {\n+        \"type\": \"watch_match\",\n+        \"session_id\": \"proc_xyz\",\n+        \"command\": \"tail -f log\",\n+        \"pattern\": \"ERROR\",\n+        \"output\": \"ERROR: disk full\",\n+        \"suppressed\": 0,\n+    }\n+    result = format_process_notification(evt)\n+    assert 'watch pattern \"ERROR\"' in result\n+    assert \"Matched output:\\nERROR: disk full\" in result\n+\n+\n+def test_format_watch_match_with_suppressed():\n+    evt = {\n+        \"type\": \"watch_match\",\n+        \"session_id\": \"proc_xyz\",\n+        \"command\": \"tail -f log\",\n+        \"pattern\": \"WARN\",\n+        \"output\": \"WARN: low mem\",\n+        \"suppressed\": 3,\n+    }\n+    result = format_process_notification(evt)\n+    assert \"3 earlier matches were suppressed\" in result\n+\n+\n+def test_format_watch_disabled_event():\n+    evt = {\n+        \"type\": \"watch_disabled\",\n+        \"message\": \"Watch disabled for proc_xyz: too many matches\",\n+    }\n+    result = format_process_notification(evt)\n+    assert \"[IMPORTANT: Watch disabled for proc_xyz\" in result\n+\n+\n+def test_format_returns_none_for_empty_event():\n+    evt = {}\n+    result = format_process_notification(evt)\n+    assert result is not None\n+    assert \"unknown\" in result\n+\n+\n+def test_drain_notifications_returns_pending_events():\n+    from tools.process_registry import process_registry\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    process_registry.completion_queue.put({\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_drain1\",\n+        \"command\": \"echo hi\",\n+        \"exit_code\": 0,\n+        \"output\": \"hi\",\n+    })\n+    process_registry.completion_queue.put({\n+        \"type\": \"watch_match\",\n+        \"session_id\": \"proc_drain2\",\n+        \"command\": \"tail -f x\",\n+        \"pattern\": \"ERR\",\n+        \"output\": \"ERR found\",\n+        \"suppressed\": 0,\n+    })\n+\n+    try:\n+        results = process_registry.drain_notifications()\n+        assert len(results) == 2\n+        assert results[0][0][\"session_id\"] == \"proc_drain1\"\n+        assert \"proc_drain1 completed\" in results[0][1]\n+        assert results[1][0][\"session_id\"] == \"proc_drain2\"\n+        assert \"watch pattern\" in results[1][1]\n+    finally:\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+        process_registry._completion_consumed.discard(\"proc_drain1\")\n+        process_registry._completion_consumed.discard(\"proc_drain2\")\n+\n+\n+def test_drain_notifications_skips_consumed():\n+    from tools.process_registry import process_registry\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    process_registry._completion_consumed.add(\"proc_consumed\")\n+    process_registry.completion_queue.put({\n+        \"type\": \"completion\",\n+        \"session_id\": \"proc_consumed\",\n+        \"command\": \"echo done\",\n+        \"exit_code\": 0,\n+        \"output\": \"done\",\n+    })\n+\n+    try:\n+        results = process_registry.drain_notifications()\n+        assert len(results) == 0\n+    finally:\n+        process_registry._completion_consumed.discard(\"proc_consumed\")\n+        while not process_registry.completion_queue.empty():\n+            process_registry.completion_queue.get_nowait()\n+\n+\n+def test_drain_notifications_empty_queue():\n+    from tools.process_registry import process_registry\n+\n+    while not process_registry.completion_queue.empty():\n+        process_registry.completion_queue.get_nowait()\n+\n+    results = process_registry.drain_notifications()\n+    assert results == []"]},{"file":"tools/process_registry.py","hunks":["@@ -826,6 +826,26 @@ def is_completion_consumed(self, session_id: str) -> bool:\n         \"\"\"Check if a completion notification was already consumed via wait/poll/log.\"\"\"\n         return session_id in self._completion_consumed\n \n+    def drain_notifications(self) -> \"list[tuple[dict, str]]\":\n+        \"\"\"Pop all pending notification events and return formatted pairs.\n+\n+        Returns a list of (raw_event, formatted_text) tuples.\n+        Skips completion events that were already consumed via wait/poll/log.\n+        \"\"\"\n+        results = []\n+        while not self.completion_queue.empty():\n+            try:\n+                evt = self.completion_queue.get_nowait()\n+            except Exception:\n+                break\n+            _evt_sid = evt.get(\"session_id\", \"\")\n+            if evt.get(\"type\") == \"completion\" and self.is_completion_consumed(_evt_sid):\n+                continue\n+            text = format_process_notification(evt)\n+            if text:\n+                results.append((evt, text))\n+        return results\n+\n     def get(self, session_id: str) -> Optional[ProcessSession]:\n         \"\"\"Get a session by ID (running or finished).\"\"\"\n         with self._lock:","@@ -1388,6 +1408,44 @@ def recover_from_checkpoint(self) -> int:\n process_registry = ProcessRegistry()\n \n \n+def format_process_notification(evt: dict) -> \"str | None\":\n+    \"\"\"Format a process notification event into a [IMPORTANT: ...] message.\n+\n+    Handles completion events (notify_on_complete), watch pattern matches,\n+    and watch disabled events from the unified completion_queue.\n+    \"\"\"\n+    evt_type = evt.get(\"type\", \"completion\")\n+    _sid = evt.get(\"session_id\", \"unknown\")\n+    _cmd = evt.get(\"command\", \"unknown\")\n+\n+    if evt_type == \"watch_disabled\":\n+        return f\"[IMPORTANT: {evt.get('message', '')}]\"\n+\n+    if evt_type == \"watch_match\":\n+        _pat = evt.get(\"pattern\", \"?\")\n+        _out = evt.get(\"output\", \"\")\n+        _sup = evt.get(\"suppressed\", 0)\n+        text = (\n+            f\"[IMPORTANT: Background process {_sid} matched \"\n+            f\"watch pattern \\\"{_pat}\\\".\\n\"\n+            f\"Command: {_cmd}\\n\"\n+            f\"Matched output:\\n{_out}\"\n+        )\n+        if _sup:\n+            text += f\"\\n({_sup} earlier matches were suppressed by rate limit)\"\n+        text += \"]\"\n+        return text\n+\n+    _exit = evt.get(\"exit_code\", \"?\")\n+    _out = evt.get(\"output\", \"\")\n+    return (\n+        f\"[IMPORTANT: Background process {_sid} completed \"\n+        f\"(exit code {_exit}).\\n\"\n+        f\"Command: {_cmd}\\n\"\n+        f\"Output:\\n{_out}]\"\n+    )\n+\n+\n # ---------------------------------------------------------------------------\n # Registry -- the \"process\" tool schema + handler\n # ---------------------------------------------------------------------------"]},{"file":"tui_gateway/server.py","hunks":["@@ -287,6 +287,9 @@ def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> No\n     if not session or session.get(\"_finalized\"):\n         return\n     session[\"_finalized\"] = True\n+    stop_event = session.get(\"_notif_stop\")\n+    if stop_event is not None:\n+        stop_event.set()\n \n     agent = session.get(\"agent\")\n     lock = session.get(\"history_lock\")","@@ -579,6 +582,7 @@ def _build() -> None:\n                 pass\n \n             _wire_callbacks(sid)\n+            _sessions[sid][\"_notif_stop\"] = _start_notification_poller(sid, _sessions[sid])\n             _notify_session_boundary(\"on_session_reset\", key)\n \n             info = _session_info(agent)","@@ -1955,6 +1959,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):\n         # session startup resilient).\n         pass\n     _wire_callbacks(sid)\n+    _sessions[sid][\"_notif_stop\"] = _start_notification_poller(sid, _sessions[sid])\n     _notify_session_boundary(\"on_session_reset\", key)\n     _emit(\"session.info\", sid, _session_info(agent))\n ","@@ -3027,6 +3032,105 @@ def run_after_agent_ready() -> None:\n     return _ok(rid, {\"status\": \"streaming\"})\n \n \n+def _notification_poller_loop(\n+    stop_event: threading.Event, sid: str, session: dict\n+) -> None:\n+    \"\"\"Poll completion_queue and dispatch notifications autonomously.\n+\n+    Runs in a daemon thread started by _init_session(). Emits a\n+    status.update (kind=process) for user visibility, then chains an\n+    agent turn via _run_prompt_submit if the session is idle.\n+\n+    NOTE: The completion_queue is global (one per process). If multiple\n+    TUI sessions coexist, whichever poller wakes first grabs the event,\n+    even if the process was started by a different session. This matches\n+    CLI/gateway behavior (single session per process).\n+    \"\"\"\n+    from tools.process_registry import process_registry, format_process_notification\n+\n+    while not stop_event.is_set() and not session.get(\"_finalized\"):\n+        try:\n+            evt = process_registry.completion_queue.get(timeout=0.5)\n+        except Exception:\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\n+\n+        text = format_process_notification(evt)\n+        if not text:\n+            continue\n+\n+        _emit(\"status.update\", sid, {\"kind\": \"process\", \"text\": text})\n+\n+        with session[\"history_lock\"]:\n+            if session.get(\"running\"):\n+                process_registry.completion_queue.put(evt)\n+                continue\n+            session[\"running\"] = True\n+\n+        rid = f\"__notif__{int(time.time() * 1000)}\"\n+        try:\n+            _emit(\"message.start\", sid)\n+            _run_prompt_submit(rid, sid, session, text)\n+        except Exception as exc:\n+            print(\n+                f\"[tui_gateway] notification poller dispatch failed: \"\n+                f\"{type(exc).__name__}: {exc}\",\n+                file=sys.stderr,\n+            )\n+            with session[\"history_lock\"]:\n+                session[\"running\"] = False\n+\n+    # Drain any remaining events after stop signal (process all pending\n+    # before exiting so nothing is lost on shutdown).\n+    while not process_registry.completion_queue.empty():\n+        try:\n+            evt = process_registry.completion_queue.get_nowait()\n+        except Exception:\n+            break\n+        _evt_sid = evt.get(\"session_id\", \"\")\n+        if evt.get(\"type\") == \"completion\" and process_registry.is_completion_consumed(_evt_sid):\n+            continue\n+        text = format_process_notification(evt)\n+        if not text:\n+            continue\n+\n+        _emit(\"status.update\", sid, {\"kind\": \"process\", \"text\": text})\n+\n+        with session[\"history_lock\"]:\n+            if session.get(\"running\"):\n+                process_registry.completion_queue.put(evt)\n+                break\n+            session[\"running\"] = True\n+\n+        rid = f\"__notif__{int(time.time() * 1000)}\"\n+        try:\n+            _emit(\"message.start\", sid)\n+            _run_prompt_submit(rid, sid, session, text)\n+        except Exception as exc:\n+            print(\n+                f\"[tui_gateway] notification poller dispatch failed: \"\n+                f\"{type(exc).__name__}: {exc}\",\n+                file=sys.stderr,\n+            )\n+            with session[\"history_lock\"]:\n+                session[\"running\"] = False\n+\n+\n+def _start_notification_poller(sid: str, session: dict) -> threading.Event:\n+    \"\"\"Start the background notification poller for a TUI session.\"\"\"\n+    stop = threading.Event()\n+    t = threading.Thread(\n+        target=_notification_poller_loop,\n+        args=(stop, sid, session),\n+        daemon=True,\n+    )\n+    t.start()\n+    return stop\n+\n+\n def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:\n     with session[\"history_lock\"]:\n         history = list(session[\"history\"])","@@ -3385,6 +3489,36 @@ def _stream(delta):\n                 with session[\"history_lock\"]:\n                     session[\"running\"] = False\n \n+        # Drain completion notifications that arrived during this turn.\n+        # The background poller handles between-turn delivery; this is\n+        # the safety net for events that arrived mid-turn.\n+        try:\n+            from tools.process_registry import process_registry\n+\n+            for _evt, synth in process_registry.drain_notifications():\n+                with session[\"history_lock\"]:\n+                    if session.get(\"running\"):\n+                        process_registry.completion_queue.put(_evt)\n+                        break\n+                    session[\"running\"] = True\n+                try:\n+                    _emit(\"message.start\", sid)\n+                    _run_prompt_submit(rid, sid, session, synth)\n+                except Exception as _n_exc:\n+                    print(\n+                        f\"[tui_gateway] completion notification dispatch failed: \"\n+                        f\"{type(_n_exc).__name__}: {_n_exc}\",\n+                        file=sys.stderr,\n+                    )\n+                    with session[\"history_lock\"]:\n+                        session[\"running\"] = False\n+        except Exception as _drain_exc:\n+            print(\n+                f\"[tui_gateway] completion queue drain failed: \"\n+                f\"{type(_drain_exc).__name__}: {_drain_exc}\",\n+                file=sys.stderr,\n+            )\n+\n     threading.Thread(target=run, daemon=True).start()\n \n \n"]}]},{"kind":"pr","number":31749,"title":"fix(gateway): deliver background process notifications to WebUI via api_server SSE stream","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}