{"repo":"NousResearch/hermes-agent","public_id":"7286","display_label":"Gateway restart handling of in-flight work and queue mode","summary":"The members appear to concern the gateway's behavior around restarts: one issue reports that busy_input_mode=queue is not honored during a Discord self-restart, and two items share the title 'fix(gateway): drain in-flight work before restart'. Titles alone do not show how closely the issue and the two fixes are connected or what their outcomes are.","subsystem":null,"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":7503,"merged_at":"2026-04-11T04:18:35+00:00"},"resolution":{"state":"resolved","by":"pr-7503"},"freshness":{"state":"stale","last_verified_at":"2026-04-11T04:18:35+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":7286,"pr_number":7503,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-11T04:18:35+00:00"}],"members":[{"kind":"issue","number":7286,"title":"bug(gateway): busy_input_mode=queue is not honored during Discord self-restart","url":null,"state":"closed"},{"kind":"pr","number":7290,"title":"fix(gateway): drain in-flight work before restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7503","number":7503}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7503,"title":"fix(gateway): drain in-flight work before restart","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","gateway/platforms/base.py","gateway/restart.py","gateway/run.py","gateway/status.py","hermes_cli/commands.py","hermes_cli/config.py","hermes_cli/gateway.py","tests/gateway/restart_test_helpers.py","tests/gateway/test_gateway_shutdown.py","tests/gateway/test_restart_drain.py","tests/gateway/test_session_boundary_hooks.py","tests/gateway/test_session_race_guard.py","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"cli-config.yaml.example","hunks":["@@ -480,6 +480,12 @@ agent:\n   # Fires once per run when inactivity reaches this threshold (seconds).\n   # Set to 0 to disable the warning.\n   # gateway_timeout_warning: 900\n+\n+  # Graceful drain timeout for gateway stop/restart (seconds).\n+  # The gateway stops accepting new work, waits for in-flight agents to\n+  # finish, then interrupts anything still running after this timeout.\n+  # 0 = no drain, interrupt immediately.\n+  # restart_drain_timeout: 60\n   \n   # Enable verbose logging\n   verbose: false"]},{"file":"gateway/platforms/base.py","hunks":["@@ -673,6 +673,32 @@ class SendResult:\n     retryable: bool = False  # True for transient connection errors — base will retry automatically\n \n \n+def merge_pending_message_event(\n+    pending_messages: Dict[str, MessageEvent],\n+    session_key: str,\n+    event: MessageEvent,\n+) -> None:\n+    \"\"\"Store or merge a pending event for a session.\n+\n+    Photo bursts/albums often arrive as multiple near-simultaneous PHOTO\n+    events. Merge those into the existing queued event so the next turn sees\n+    the whole burst, while non-photo follow-ups still replace the pending\n+    event normally.\n+    \"\"\"\n+    existing = pending_messages.get(session_key)\n+    if (\n+        existing\n+        and getattr(existing, \"message_type\", None) == MessageType.PHOTO\n+        and event.message_type == MessageType.PHOTO\n+    ):\n+        existing.media_urls.extend(event.media_urls)\n+        existing.media_types.extend(event.media_types)\n+        if event.text:\n+            existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)\n+        return\n+    pending_messages[session_key] = event\n+\n+\n # Error substrings that indicate a transient *connection* failure worth retrying.\n # \"timeout\" / \"timed out\" / \"readtimeout\" / \"writetimeout\" are intentionally\n # excluded: a read/write timeout on a non-idempotent call (e.g. send_message)","@@ -727,6 +753,7 @@ def __init__(self, config: PlatformConfig, platform: Platform):\n         # working on a task after --replace or manual restarts.\n         self._background_tasks: set[asyncio.Task] = set()\n         self._expected_cancelled_tasks: set[asyncio.Task] = set()\n+        self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None\n         # Chats where auto-TTS on voice input is disabled (set by /voice off)\n         self._auto_tts_disabled_chats: set = set()\n         # Chats where typing indicator is paused (e.g. during approval waits).","@@ -815,6 +842,10 @@ def set_message_handler(self, handler: MessageHandler) -> None:\n         an optional response string.\n         \"\"\"\n         self._message_handler = handler\n+\n+    def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None:\n+        \"\"\"Set an optional handler for messages arriving during active sessions.\"\"\"\n+        self._busy_session_handler = handler\n     \n     def set_session_store(self, session_store: Any) -> None:\n         \"\"\"","@@ -1396,7 +1427,7 @@ async def handle_message(self, event: MessageEvent) -> None:\n             # session lifecycle and its cleanup races with the running task\n             # (see PR #4926).\n             cmd = event.get_command()\n-            if cmd in (\"approve\", \"deny\", \"status\", \"stop\", \"new\", \"reset\", \"background\"):\n+            if cmd in (\"approve\", \"deny\", \"status\", \"stop\", \"new\", \"reset\", \"background\", \"restart\"):\n                 logger.debug(\n                     \"[%s] Command '/%s' bypassing active-session guard for %s\",\n                     self.name, cmd, session_key,","@@ -1415,19 +1446,19 @@ async def handle_message(self, event: MessageEvent) -> None:\n                     logger.error(\"[%s] Command '/%s' dispatch failed: %s\", self.name, cmd, e, exc_info=True)\n                 return\n \n+            if self._busy_session_handler is not None:\n+                try:\n+                    if await self._busy_session_handler(event, session_key):\n+                        return\n+                except Exception as e:\n+                    logger.error(\"[%s] Busy-session handler failed: %s\", self.name, e, exc_info=True)\n+\n             # Special case: photo bursts/albums frequently arrive as multiple near-\n             # simultaneous messages. Queue them without interrupting the active run,\n             # then process them immediately after the current task finishes.\n             if event.message_type == MessageType.PHOTO:\n                 logger.debug(\"[%s] Queuing photo follow-up for session %s without interrupt\", self.name, session_key)\n-                existing = self._pending_messages.get(session_key)\n-                if existing and existing.message_type == MessageType.PHOTO:\n-                    existing.media_urls.extend(event.media_urls)\n-                    existing.media_types.extend(event.media_types)\n-                    if event.text:\n-                        existing.text = self._merge_caption(existing.text, event.text)\n-                else:\n-                    self._pending_messages[session_key] = event\n+                merge_pending_message_event(self._pending_messages, session_key, event)\n                 return  # Don't interrupt now - will run after current task completes\n \n             # Default behavior for non-photo follow-ups: interrupt the running agent"]},{"file":"gateway/restart.py","hunks":["@@ -0,0 +1,20 @@\n+\"\"\"Shared gateway restart constants and parsing helpers.\"\"\"\n+\n+from hermes_cli.config import DEFAULT_CONFIG\n+\n+# EX_TEMPFAIL from sysexits.h — used to ask the service manager to restart\n+# the gateway after a graceful drain/reload path completes.\n+GATEWAY_SERVICE_RESTART_EXIT_CODE = 75\n+\n+DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT = float(\n+    DEFAULT_CONFIG[\"agent\"][\"restart_drain_timeout\"]\n+)\n+\n+\n+def parse_restart_drain_timeout(raw: object) -> float:\n+    \"\"\"Parse a configured drain timeout, falling back to the shared default.\"\"\"\n+    try:\n+        value = float(raw) if str(raw or \"\").strip() else DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+    except (TypeError, ValueError):\n+        return DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+    return max(0.0, value)"]},{"file":"gateway/run.py","hunks":["@@ -186,6 +186,12 @@ def _ensure_ssl_certs() -> None:\n                 os.environ[\"HERMES_AGENT_TIMEOUT\"] = str(_agent_cfg[\"gateway_timeout\"])\n             if \"gateway_timeout_warning\" in _agent_cfg and \"HERMES_AGENT_TIMEOUT_WARNING\" not in os.environ:\n                 os.environ[\"HERMES_AGENT_TIMEOUT_WARNING\"] = str(_agent_cfg[\"gateway_timeout_warning\"])\n+            if \"restart_drain_timeout\" in _agent_cfg and \"HERMES_RESTART_DRAIN_TIMEOUT\" not in os.environ:\n+                os.environ[\"HERMES_RESTART_DRAIN_TIMEOUT\"] = str(_agent_cfg[\"restart_drain_timeout\"])\n+        _display_cfg = _cfg.get(\"display\", {})\n+        if _display_cfg and isinstance(_display_cfg, dict):\n+            if \"busy_input_mode\" in _display_cfg and \"HERMES_GATEWAY_BUSY_INPUT_MODE\" not in os.environ:\n+                os.environ[\"HERMES_GATEWAY_BUSY_INPUT_MODE\"] = str(_display_cfg[\"busy_input_mode\"])\n         # Timezone: bridge config.yaml → HERMES_TIMEZONE env var.\n         # HERMES_TIMEZONE from .env takes precedence (already in os.environ).\n         _tz_cfg = _cfg.get(\"timezone\", \"\")","@@ -235,7 +241,17 @@ def _ensure_ssl_certs() -> None:\n     build_session_key,\n )\n from gateway.delivery import DeliveryRouter\n-from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+    merge_pending_message_event,\n+)\n+from gateway.restart import (\n+    DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,\n+    GATEWAY_SERVICE_RESTART_EXIT_CODE,\n+    parse_restart_drain_timeout,\n+)\n \n \n def _normalize_whatsapp_identifier(value: str) -> str:","@@ -471,6 +487,16 @@ class GatewayRunner:\n     # Class-level defaults so partial construction in tests doesn't\n     # blow up on attribute access.\n     _running_agents_ts: Dict[str, float] = {}\n+    _busy_input_mode: str = \"interrupt\"\n+    _restart_drain_timeout: float = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+    _exit_code: Optional[int] = None\n+    _draining: bool = False\n+    _restart_requested: bool = False\n+    _restart_task_started: bool = False\n+    _restart_detached: bool = False\n+    _restart_via_service: bool = False\n+    _stop_task: Optional[asyncio.Task] = None\n+    _session_model_overrides: Dict[str, Dict[str, str]] = {}\n     \n     def __init__(self, config: Optional[GatewayConfig] = None):\n         self.config = config or load_gateway_config()","@@ -483,6 +509,8 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n         self._reasoning_config = self._load_reasoning_config()\n         self._service_tier = self._load_service_tier()\n         self._show_reasoning = self._load_show_reasoning()\n+        self._busy_input_mode = self._load_busy_input_mode()\n+        self._restart_drain_timeout = self._load_restart_drain_timeout()\n         self._provider_routing = self._load_provider_routing()\n         self._fallback_model = self._load_fallback_model()\n         self._smart_model_routing = self._load_smart_model_routing()","@@ -499,6 +527,13 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n         self._exit_cleanly = False\n         self._exit_with_failure = False\n         self._exit_reason: Optional[str] = None\n+        self._exit_code: Optional[int] = None\n+        self._draining = False\n+        self._restart_requested = False\n+        self._restart_task_started = False\n+        self._restart_detached = False\n+        self._restart_via_service = False\n+        self._stop_task: Optional[asyncio.Task] = None\n         \n         # Track running agents per session for interrupt support\n         # Key: session_key, Value: AIAgent instance","@@ -759,6 +794,10 @@ def should_exit_with_failure(self) -> bool:\n     def exit_reason(self) -> Optional[str]:\n         return self._exit_reason\n \n+    @property\n+    def exit_code(self) -> Optional[int]:\n+        return self._exit_code\n+\n     def _session_key_for_source(self, source: SessionSource) -> str:\n         \"\"\"Resolve the current session key for a source, honoring gateway config when available.\"\"\"\n         if hasattr(self, \"session_store\") and self.session_store is not None:","@@ -868,6 +907,30 @@ def _request_clean_exit(self, reason: str) -> None:\n         self._exit_cleanly = True\n         self._exit_reason = reason\n         self._shutdown_event.set()\n+\n+    def _running_agent_count(self) -> int:\n+        return len(self._running_agents)\n+\n+    def _status_action_label(self) -> str:\n+        return \"restart\" if self._restart_requested else \"shutdown\"\n+\n+    def _status_action_gerund(self) -> str:\n+        return \"restarting\" if self._restart_requested else \"shutting down\"\n+\n+    def _queue_during_drain_enabled(self) -> bool:\n+        return self._restart_requested and self._busy_input_mode == \"queue\"\n+\n+    def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None:\n+        try:\n+            from gateway.status import write_runtime_status\n+            write_runtime_status(\n+                gateway_state=gateway_state,\n+                exit_reason=exit_reason,\n+                restart_requested=self._restart_requested,\n+                active_agents=self._running_agent_count(),\n+            )\n+        except Exception:\n+            pass\n     \n     @staticmethod\n     def _load_prefill_messages() -> List[Dict[str, Any]]:","@@ -994,6 +1057,48 @@ def _load_show_reasoning() -> bool:\n             pass\n         return False\n \n+    @staticmethod\n+    def _load_busy_input_mode() -> str:\n+        \"\"\"Load gateway drain-time busy-input behavior from config/env.\"\"\"\n+        mode = os.getenv(\"HERMES_GATEWAY_BUSY_INPUT_MODE\", \"\").strip().lower()\n+        if not mode:\n+            try:\n+                import yaml as _y\n+                cfg_path = _hermes_home / \"config.yaml\"\n+                if cfg_path.exists():\n+                    with open(cfg_path, encoding=\"utf-8\") as _f:\n+                        cfg = _y.safe_load(_f) or {}\n+                    mode = str(cfg.get(\"display\", {}).get(\"busy_input_mode\", \"\") or \"\").strip().lower()\n+            except Exception:\n+                pass\n+        return \"queue\" if mode == \"queue\" else \"interrupt\"\n+\n+    @staticmethod\n+    def _load_restart_drain_timeout() -> float:\n+        \"\"\"Load graceful gateway restart/stop drain timeout in seconds.\"\"\"\n+        raw = os.getenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", \"\").strip()\n+        if not raw:\n+            try:\n+                import yaml as _y\n+                cfg_path = _hermes_home / \"config.yaml\"\n+                if cfg_path.exists():\n+                    with open(cfg_path, encoding=\"utf-8\") as _f:\n+                        cfg = _y.safe_load(_f) or {}\n+                    raw = str(cfg.get(\"agent\", {}).get(\"restart_drain_timeout\", \"\") or \"\").strip()\n+            except Exception:\n+                pass\n+        value = parse_restart_drain_timeout(raw)\n+        if raw and value == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT:\n+            try:\n+                float(raw)\n+            except (TypeError, ValueError):\n+                logger.warning(\n+                    \"Invalid restart_drain_timeout '%s', using default %.0fs\",\n+                    raw,\n+                    DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,\n+                )\n+        return value\n+\n     @staticmethod\n     def _load_background_notifications_mode() -> str:\n         \"\"\"Load background process notification mode from config or env var.","@@ -1078,6 +1183,155 @@ def _load_smart_model_routing() -> dict:\n             pass\n         return {}\n \n+    def _snapshot_running_agents(self) -> Dict[str, Any]:\n+        return {\n+            session_key: agent\n+            for session_key, agent in self._running_agents.items()\n+            if agent is not _AGENT_PENDING_SENTINEL\n+        }\n+\n+    def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None:\n+        adapter = self.adapters.get(event.source.platform)\n+        if not adapter:\n+            return\n+        merge_pending_message_event(adapter._pending_messages, session_key, event)\n+\n+    async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:\n+        if not self._draining:\n+            return False\n+\n+        adapter = self.adapters.get(event.source.platform)\n+        if not adapter:\n+            return True\n+\n+        thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+        if self._queue_during_drain_enabled():\n+            self._queue_or_replace_pending_event(session_key, event)\n+            message = f\"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back.\"\n+        else:\n+            message = f\"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now.\"\n+\n+        await adapter._send_with_retry(\n+            chat_id=event.source.chat_id,\n+            content=message,\n+            reply_to=event.message_id,\n+            metadata=thread_meta,\n+        )\n+        return True\n+\n+    async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]:\n+        snapshot = self._snapshot_running_agents()\n+        last_active_count = self._running_agent_count()\n+        last_status_at = 0.0\n+\n+        def _maybe_update_status(force: bool = False) -> None:\n+            nonlocal last_active_count, last_status_at\n+            now = asyncio.get_running_loop().time()\n+            active_count = self._running_agent_count()\n+            if force or active_count != last_active_count or (now - last_status_at) >= 1.0:\n+                self._update_runtime_status(\"draining\")\n+                last_active_count = active_count\n+                last_status_at = now\n+\n+        if not self._running_agents:\n+            _maybe_update_status(force=True)\n+            return snapshot, False\n+\n+        _maybe_update_status(force=True)\n+        if timeout <= 0:\n+            return snapshot, True\n+\n+        deadline = asyncio.get_running_loop().time() + timeout\n+        while self._running_agents and asyncio.get_running_loop().time() < deadline:\n+            _maybe_update_status()\n+            await asyncio.sleep(0.1)\n+        timed_out = bool(self._running_agents)\n+        _maybe_update_status(force=True)\n+        return snapshot, timed_out\n+\n+    def _interrupt_running_agents(self, reason: str) -> None:\n+        for session_key, agent in list(self._running_agents.items()):\n+            if agent is _AGENT_PENDING_SENTINEL:\n+                continue\n+            try:\n+                agent.interrupt(reason)\n+                logger.debug(\"Interrupted running agent for session %s during shutdown\", session_key[:20])\n+            except Exception as e:\n+                logger.debug(\"Failed interrupting agent during shutdown: %s\", e)\n+\n+    def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:\n+        for agent in active_agents.values():\n+            try:\n+                from hermes_cli.plugins import invoke_hook as _invoke_hook\n+                _invoke_hook(\n+                    \"on_session_finalize\",\n+                    session_id=getattr(agent, \"session_id\", None),\n+                    platform=\"gateway\",\n+                )\n+            except Exception:\n+                pass\n+            try:\n+                if hasattr(agent, \"shutdown_memory_provider\"):\n+                    agent.shutdown_memory_provider()\n+            except Exception:\n+                pass\n+            # Close tool resources (terminal sandboxes, browser daemons,\n+            # background processes, httpx clients) to prevent zombie\n+            # process accumulation.\n+            try:\n+                if hasattr(agent, 'close'):\n+                    agent.close()\n+            except Exception:\n+                pass\n+\n+    async def _launch_detached_restart_command(self) -> None:\n+        import shutil\n+        import subprocess\n+\n+        hermes_cmd = _resolve_hermes_bin()\n+        if not hermes_cmd:\n+            logger.error(\"Could not locate hermes binary for detached /restart\")\n+            return\n+\n+        current_pid = os.getpid()\n+        cmd = \" \".join(shlex.quote(part) for part in hermes_cmd)\n+        shell_cmd = (\n+            f\"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; \"\n+            f\"{cmd} gateway restart\"\n+        )\n+        setsid_bin = shutil.which(\"setsid\")\n+        if setsid_bin:\n+            subprocess.Popen(\n+                [setsid_bin, \"bash\", \"-lc\", shell_cmd],\n+                stdout=subprocess.DEVNULL,\n+                stderr=subprocess.DEVNULL,\n+                start_new_session=True,\n+            )\n+        else:\n+            subprocess.Popen(\n+                [\"bash\", \"-lc\", shell_cmd],\n+                stdout=subprocess.DEVNULL,\n+                stderr=subprocess.DEVNULL,\n+                start_new_session=True,\n+            )\n+\n+    def request_restart(self, *, detached: bool = False, via_service: bool = False) -> bool:\n+        if self._restart_task_started:\n+            return False\n+        self._restart_requested = True\n+        self._restart_detached = detached\n+        self._restart_via_service = via_service\n+        self._restart_task_started = True\n+\n+        async def _run_restart() -> None:\n+            await asyncio.sleep(0.05)\n+            await self.stop(restart=True, detached_restart=detached, service_restart=via_service)\n+\n+        task = asyncio.create_task(_run_restart())\n+        self._background_tasks.add(task)\n+        task.add_done_callback(self._background_tasks.discard)\n+        return True\n+\n     async def start(self) -> bool:\n         \"\"\"\n         Start the gateway and all configured platform adapters.","@@ -1165,6 +1419,7 @@ async def start(self) -> bool:\n             adapter.set_message_handler(self._handle_message)\n             adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)\n             adapter.set_session_store(self.session_store)\n+            adapter.set_busy_session_handler(self._handle_active_session_busy_message)\n             \n             # Try to connect\n             logger.info(\"Connecting to %s...\", platform.value)","@@ -1240,11 +1495,7 @@ async def start(self) -> bool:\n         self.delivery_router.adapters = self.adapters\n         \n         self._running = True\n-        try:\n-            from gateway.status import write_runtime_status\n-            write_runtime_status(gateway_state=\"running\", exit_reason=None)\n-        except Exception:\n-            pass\n+        self._update_runtime_status(\"running\")\n         \n         # Emit gateway:startup hook\n         hook_count = len(self.hooks.loaded_hooks)","@@ -1479,6 +1730,7 @@ async def _platform_reconnect_watcher(self) -> None:\n                     adapter.set_message_handler(self._handle_message)\n                     adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)\n                     adapter.set_session_store(self.session_store)\n+                    adapter.set_busy_session_handler(self._handle_active_session_busy_message)\n \n                     success = await adapter.connect()\n                     if success:","@@ -1525,90 +1777,108 @@ async def _platform_reconnect_watcher(self) -> None:\n                     return\n                 await asyncio.sleep(1)\n \n-    async def stop(self) -> None:\n+    async def stop(\n+        self,\n+        *,\n+        restart: bool = False,\n+        detached_restart: bool = False,\n+        service_restart: bool = False,\n+    ) -> None:\n         \"\"\"Stop the gateway and disconnect all adapters.\"\"\"\n-        logger.info(\"Stopping gateway...\")\n-        self._running = False\n+        if restart:\n+            self._restart_requested = True\n+            self._restart_detached = detached_restart\n+            self._restart_via_service = service_restart\n+        if self._stop_task is not None:\n+            await self._stop_task\n+            return\n \n-        for session_key, agent in list(self._running_agents.items()):\n-            if agent is _AGENT_PENDING_SENTINEL:\n-                continue\n-            try:\n-                agent.interrupt(\"Gateway shutting down\")\n-                logger.debug(\"Interrupted running agent for session %s during shutdown\", session_key[:20])\n-            except Exception as e:\n-                logger.debug(\"Failed interrupting agent during shutdown: %s\", e)\n-            # Fire plugin on_session_finalize hook before memory shutdown\n+        async def _stop_impl() -> None:\n+            logger.info(\n+                \"Stopping gateway%s...\",\n+                \" for restart\" if self._restart_requested else \"\",\n+            )\n+            self._running = False\n+            self._draining = True\n+\n+            timeout = self._restart_drain_timeout\n+            active_agents, timed_out = await self._drain_active_agents(timeout)\n+            if timed_out:\n+                logger.warning(\n+                    \"Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.\",\n+                    timeout,\n+                    self._running_agent_count(),\n+                )\n+                self._interrupt_running_agents(\n+                    \"Gateway restarting\" if self._restart_requested else \"Gateway shutting down\"\n+                )\n+                interrupt_deadline = asyncio.get_running_loop().time() + 5.0\n+                while self._running_agents and asyncio.get_running_loop().time() < interrupt_deadline:\n+                    self._update_runtime_status(\"draining\")\n+                    await asyncio.sleep(0.1)\n+\n+            if self._restart_requested and self._restart_detached:\n+                try:\n+                    await self._launch_detached_restart_command()\n+                except Exception as e:\n+                    logger.error(\"Failed to launch detached gateway restart: %s\", e)\n+\n+            self._finalize_shutdown_agents(active_agents)\n+\n+            for platform, adapter in list(self.adapters.items()):\n+                try:\n+                    await adapter.cancel_background_tasks()\n+                except Exception as e:\n+                    logger.debug(\"✗ %s background-task cancel error: %s\", platform.value, e)\n+                try:\n+                    await adapter.disconnect()\n+                    logger.info(\"✓ %s disconnected\", platform.value)\n+                except Exception as e:\n+                    logger.error(\"✗ %s disconnect error: %s\", platform.value, e)\n+\n+            for _task in list(self._background_tasks):\n+                if _task is self._stop_task:\n+                    continue\n+                _task.cancel()\n+            self._background_tasks.clear()\n+\n+            self.adapters.clear()\n+            self._running_agents.clear()\n+            self._pending_messages.clear()\n+            self._pending_approvals.clear()\n+            self._shutdown_event.set()\n+\n+            # Global cleanup: kill any remaining tool subprocesses not tied\n+            # to a specific agent (catch-all for zombie prevention).\n             try:\n-                from hermes_cli.plugins import invoke_hook as _invoke_hook\n-                _invoke_hook(\"on_session_finalize\",\n-                             session_id=getattr(agent, 'session_id', None),\n-                             platform=\"gateway\")\n+                from tools.process_registry import process_registry\n+                process_registry.kill_all()\n             except Exception:\n                 pass\n-            # Shut down memory provider at actual session boundary\n             try:\n-                if hasattr(agent, 'shutdown_memory_provider'):\n-                    agent.shutdown_memory_provider()\n+                from tools.terminal_tool import cleanup_all_environments\n+                cleanup_all_environments()\n             except Exception:\n                 pass\n-            # Close tool resources (terminal sandboxes, browser daemons,\n-            # background processes, httpx clients) to prevent zombie\n-            # process accumulation.\n             try:\n-                if hasattr(agent, 'close'):\n-                    agent.close()\n+                from tools.browser_tool import cleanup_all_browsers\n+                cleanup_all_browsers()\n             except Exception:\n                 pass\n \n-        for platform, adapter in list(self.adapters.items()):\n-            try:\n-                await adapter.cancel_background_tasks()\n-            except Exception as e:\n-                logger.debug(\"✗ %s background-task cancel error: %s\", platform.value, e)\n-            try:\n-                await adapter.disconnect()\n-                logger.info(\"✓ %s disconnected\", platform.value)\n-            except Exception as e:\n-                logger.error(\"✗ %s disconnect error: %s\", platform.value, e)\n+            from gateway.status import remove_pid_file\n+            remove_pid_file()\n \n-        # Cancel any pending background tasks\n-        for _task in list(self._background_tasks):\n-            _task.cancel()\n-        self._background_tasks.clear()\n+            if self._restart_requested and self._restart_via_service:\n+                self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE\n+                self._exit_reason = self._exit_reason or \"Gateway restart requested\"\n \n-        self.adapters.clear()\n-        self._running_agents.clear()\n-        self._pending_messages.clear()\n-        self._pending_approvals.clear()\n-        self._shutdown_event.set()\n+            self._draining = False\n+            self._update_runtime_status(\"stopped\", self._exit_reason)\n+            logger.info(\"Gateway stopped\")\n \n-        # Global cleanup: kill any remaining tool subprocesses not tied\n-        # to a specific agent (catch-all for zombie prevention).\n-        try:\n-            from tools.process_registry import process_registry\n-            process_registry.kill_all()\n-        except Exception:\n-            pass\n-        try:\n-            from tools.terminal_tool import cleanup_all_environments\n-            cleanup_all_environments()\n-        except Exception:\n-            pass\n-        try:\n-            from tools.browser_tool import cleanup_all_browsers\n-            cleanup_all_browsers()\n-        except Exception:\n-            pass\n-\n-        from gateway.status import remove_pid_file, write_runtime_status\n-        remove_pid_file()\n-        try:\n-            write_runtime_status(gateway_state=\"stopped\", exit_reason=self._exit_reason)\n-        except Exception:\n-            pass\n-        \n-        logger.info(\"Gateway stopped\")\n+        self._stop_task = asyncio.create_task(_stop_impl())\n+        await self._stop_task\n     \n     async def wait_for_shutdown(self) -> None:\n         \"\"\"Wait for shutdown signal.\"\"\"","@@ -2014,6 +2284,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n             _evt_cmd = event.get_command()\n             _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None\n \n+            if _cmd_def_inner and _cmd_def_inner.name == \"restart\":\n+                return await self._handle_restart_command(event)\n+\n             # /stop must hard-kill the session when an agent is running.\n             # A soft interrupt (agent.interrupt()) doesn't help when the agent\n             # is truly hung — the executor thread is blocked and never checks","@@ -2094,18 +2367,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 logger.debug(\"PRIORITY photo follow-up for session %s — queueing without interrupt\", _quick_key[:20])\n                 adapter = self.adapters.get(source.platform)\n                 if adapter:\n-                    # Reuse adapter queue semantics so photo bursts merge cleanly.\n-                    if _quick_key in adapter._pending_messages:\n-                        existing = adapter._pending_messages[_quick_key]\n-                        if getattr(existing, \"message_type\", None) == MessageType.PHOTO:\n-                            existing.media_urls.extend(event.media_urls)\n-                            existing.media_types.extend(event.media_types)\n-                            if event.text:\n-                                existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)\n-                        else:\n-                            adapter._pending_messages[_quick_key] = event\n-                    else:\n-                        adapter._pending_messages[_quick_key] = event\n+                    merge_pending_message_event(adapter._pending_messages, _quick_key, event)\n                 return None\n \n             running_agent = self._running_agents.get(_quick_key)","@@ -2123,6 +2385,14 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                 if adapter:\n                     adapter._pending_messages[_quick_key] = event\n                 return None\n+            if self._draining:\n+                if self._queue_during_drain_enabled():\n+                    self._queue_or_replace_pending_event(_quick_key, event)\n+                return (\n+                    f\"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back.\"\n+                    if self._queue_during_drain_enabled()\n+                    else f\"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now.\"\n+                )\n             logger.debug(\"PRIORITY interrupt for session %s\", _quick_key[:20])\n             running_agent.interrupt(event.text)\n             if _quick_key in self._pending_messages:","@@ -2164,6 +2434,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n \n         if canonical == \"status\":\n             return await self._handle_status_command(event)\n+\n+        if canonical == \"restart\":\n+            return await self._handle_restart_command(event)\n         \n         if canonical == \"stop\":\n             return await self._handle_stop_command(event)","@@ -2262,6 +2535,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n         if canonical == \"voice\":\n             return await self._handle_voice_command(event)\n \n+        if self._draining:\n+            return f\"⏳ Gateway is {self._status_action_gerund()} and is not accepting new work right now.\"\n+\n         # User-defined quick commands (bypass agent loop, no LLM call)\n         if command:\n             if isinstance(self.config, dict):","@@ -3556,7 +3832,21 @@ async def _handle_stop_command(self, event: MessageEvent) -> str:\n             return \"⚡ Force-stopped. The session is unlocked — you can send a new message.\"\n         else:\n             return \"No active task to stop.\"\n-    \n+\n+    async def _handle_restart_command(self, event: MessageEvent) -> str:\n+        \"\"\"Handle /restart command - drain active work, then restart the gateway.\"\"\"\n+        if self._restart_requested or self._draining:\n+            count = self._running_agent_count()\n+            if count:\n+                return f\"⏳ Draining {count} active agent(s) before restart...\"\n+            return \"⏳ Gateway restart already in progress...\"\n+\n+        active_agents = self._running_agent_count()\n+        self.request_restart(detached=True, via_service=False)\n+        if active_agents:\n+            return f\"⏳ Draining {active_agents} active agent(s) before restart...\"\n+        return \"♻ Restarting gateway...\"\n+\n     async def _handle_help_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /help command - list available commands.\"\"\"\n         from hermes_cli.commands import gateway_help_lines","@@ -3679,7 +3969,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n         # Check for session override\n         source = event.source\n         session_key = self._session_key_for_source(source)\n-        override = getattr(self, \"_session_model_overrides\", {}).get(session_key, {})\n+        override = self._session_model_overrides.get(session_key, {})\n         if override:\n             current_model = override.get(\"model\", current_model)\n             current_provider = override.get(\"provider\", current_provider)","@@ -3761,8 +4051,6 @@ async def _on_model_selected(\n                             f\"via {result.provider_label or result.target_provider}. \"\n                             f\"Adjust your self-identification accordingly.]\"\n                         )\n-                        if not hasattr(_self, \"_session_model_overrides\"):\n-                            _self._session_model_overrides = {}\n                         _self._session_model_overrides[_session_key] = {\n                             \"model\": result.new_model,\n                             \"provider\": result.target_provider,","@@ -3876,8 +4164,6 @@ async def _on_model_selected(\n         )\n \n         # Store session override so next agent creation uses the new model\n-        if not hasattr(self, \"_session_model_overrides\"):\n-            self._session_model_overrides = {}\n         self._session_model_overrides[session_key] = {\n             \"model\": result.new_model,\n             \"provider\": result.target_provider,","@@ -7363,6 +7649,8 @@ async def track_agent():\n                 await asyncio.sleep(0.05)\n             if session_key:\n                 self._running_agents[session_key] = agent_holder[0]\n+                if self._draining:\n+                    self._update_runtime_status(\"draining\")\n         \n         tracking_task = asyncio.create_task(track_agent())\n         ","@@ -7608,6 +7896,14 @@ async def _notify_long_running():\n                     except Exception:\n                         pass\n \n+            if self._draining and pending:\n+                logger.info(\n+                    \"Discarding pending follow-up for session %s during gateway %s\",\n+                    session_key[:20] if session_key else \"?\",\n+                    self._status_action_label(),\n+                )\n+                pending = None\n+\n             if pending:\n                 logger.debug(\"Processing pending message: '%s...'\", pending[:40])\n                 ","@@ -7684,6 +7980,8 @@ async def _notify_long_running():\n                 del self._running_agents[session_key]\n             if session_key:\n                 self._running_agents_ts.pop(session_key, None)\n+            if self._draining:\n+                self._update_runtime_status(\"draining\")\n             \n             # Wait for cancelled tasks\n             for task in [progress_task, interrupt_monitor, tracking_task, _notify_task]:","@@ -7881,13 +8179,21 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =\n     runner = GatewayRunner(config)\n     \n     # Set up signal handlers\n-    def signal_handler():\n+    def shutdown_signal_handler():\n         asyncio.create_task(runner.stop())\n+\n+    def restart_signal_handler():\n+        runner.request_restart(detached=False, via_service=True)\n     \n     loop = asyncio.get_event_loop()\n     for sig in (signal.SIGINT, signal.SIGTERM):\n         try:\n-            loop.add_signal_handler(sig, signal_handler)\n+            loop.add_signal_handler(sig, shutdown_signal_handler)\n+        except NotImplementedError:\n+            pass\n+    if hasattr(signal, \"SIGUSR1\"):\n+        try:\n+            loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler)\n         except NotImplementedError:\n             pass\n     ","@@ -7937,6 +8243,9 @@ def signal_handler():\n     except Exception:\n         pass\n \n+    if runner.exit_code is not None:\n+        raise SystemExit(runner.exit_code)\n+\n     return True\n \n "]},{"file":"gateway/status.py","hunks":["@@ -158,6 +158,8 @@ def _build_runtime_status_record() -> dict[str, Any]:\n     payload.update({\n         \"gateway_state\": \"starting\",\n         \"exit_reason\": None,\n+        \"restart_requested\": False,\n+        \"active_agents\": 0,\n         \"platforms\": {},\n         \"updated_at\": _utc_now_iso(),\n     })","@@ -218,6 +220,8 @@ def write_runtime_status(\n     *,\n     gateway_state: Optional[str] = None,\n     exit_reason: Optional[str] = None,\n+    restart_requested: Optional[bool] = None,\n+    active_agents: Optional[int] = None,\n     platform: Optional[str] = None,\n     platform_state: Optional[str] = None,\n     error_code: Optional[str] = None,","@@ -236,6 +240,10 @@ def write_runtime_status(\n         payload[\"gateway_state\"] = gateway_state\n     if exit_reason is not None:\n         payload[\"exit_reason\"] = exit_reason\n+    if restart_requested is not None:\n+        payload[\"restart_requested\"] = bool(restart_requested)\n+    if active_agents is not None:\n+        payload[\"active_agents\"] = max(0, int(active_agents))\n \n     if platform is not None:\n         platform_payload = payload[\"platforms\"].get(platform, {})"]},{"file":"hermes_cli/commands.py","hunks":["@@ -140,6 +140,8 @@ class CommandDef:\n     CommandDef(\"commands\", \"Browse all commands and skills (paginated)\", \"Info\",\n                gateway_only=True, args_hint=\"[page]\"),\n     CommandDef(\"help\", \"Show available commands\", \"Info\"),\n+    CommandDef(\"restart\", \"Gracefully restart the gateway after draining active runs\", \"Session\",\n+               gateway_only=True),\n     CommandDef(\"usage\", \"Show token usage and rate limits for the current session\", \"Info\"),\n     CommandDef(\"insights\", \"Show usage insights and analytics\", \"Info\",\n                args_hint=\"[days]\"),"]},{"file":"hermes_cli/config.py","hunks":["@@ -269,6 +269,11 @@ def _ensure_hermes_home_managed(home: Path):\n         # tools or receiving API responses.  Only fires when the agent has\n         # been completely idle for this duration.  0 = unlimited.\n         \"gateway_timeout\": 1800,\n+        # Graceful drain timeout for gateway stop/restart (seconds).\n+        # The gateway stops accepting new work, waits for running agents\n+        # to finish, then interrupts any remaining runs after the timeout.\n+        # 0 = no drain, interrupt immediately.\n+        \"restart_drain_timeout\": 60,\n         \"service_tier\": \"\",\n         # Tool-use enforcement: injects system prompt guidance that tells the\n         # model to actually call tools instead of describing intended actions."]},{"file":"hermes_cli/gateway.py","hunks":["@@ -15,7 +15,19 @@\n PROJECT_ROOT = Path(__file__).parent.parent.resolve()\n \n from gateway.status import terminate_pid\n-from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error\n+from gateway.restart import (\n+    DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,\n+    GATEWAY_SERVICE_RESTART_EXIT_CODE,\n+    parse_restart_drain_timeout,\n+)\n+from hermes_cli.config import (\n+    get_env_value,\n+    get_hermes_home,\n+    is_managed,\n+    managed_error,\n+    read_raw_config,\n+    save_env_value,\n+)\n # display_hermes_home is imported lazily at call sites to avoid ImportError\n # when hermes_constants is cached from a pre-update version during `hermes update`.\n from hermes_cli.setup import (","@@ -92,6 +104,59 @@ def _get_service_pids() -> set:\n     return pids\n \n \n+def _get_parent_pid(pid: int) -> int | None:\n+    \"\"\"Return the parent PID for ``pid``, or ``None`` when unavailable.\"\"\"\n+    if pid <= 1:\n+        return None\n+    try:\n+        result = subprocess.run(\n+            [\"ps\", \"-o\", \"ppid=\", \"-p\", str(pid)],\n+            capture_output=True,\n+            text=True,\n+            timeout=5,\n+        )\n+    except (FileNotFoundError, subprocess.TimeoutExpired):\n+        return None\n+    if result.returncode != 0:\n+        return None\n+    raw = result.stdout.strip()\n+    if not raw:\n+        return None\n+    try:\n+        parent_pid = int(raw.splitlines()[-1].strip())\n+    except ValueError:\n+        return None\n+    return parent_pid if parent_pid > 0 else None\n+\n+\n+def _is_pid_ancestor_of_current_process(target_pid: int) -> bool:\n+    \"\"\"Return True when ``target_pid`` is this process or one of its ancestors.\"\"\"\n+    if target_pid <= 0:\n+        return False\n+\n+    pid = os.getpid()\n+    seen: set[int] = set()\n+    while pid and pid not in seen:\n+        if pid == target_pid:\n+            return True\n+        seen.add(pid)\n+        pid = _get_parent_pid(pid) or 0\n+    return False\n+\n+\n+def _request_gateway_self_restart(pid: int) -> bool:\n+    \"\"\"Ask a running gateway ancestor to restart itself asynchronously.\"\"\"\n+    if not hasattr(signal, \"SIGUSR1\"):\n+        return False\n+    if not _is_pid_ancestor_of_current_process(pid):\n+        return False\n+    try:\n+        os.kill(pid, signal.SIGUSR1)\n+    except (ProcessLookupError, PermissionError, OSError):\n+        return False\n+    return True\n+\n+\n def find_gateway_pids(exclude_pids: set | None = None) -> list:\n     \"\"\"Find PIDs of running gateway processes.\n ","@@ -665,6 +730,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)\n             path_entries.append(resolved_node_dir)\n \n     common_bin_paths = [\"/usr/local/sbin\", \"/usr/local/bin\", \"/usr/sbin\", \"/usr/bin\", \"/sbin\", \"/bin\"]\n+    restart_timeout = max(60, int(_get_restart_drain_timeout() or 0))\n \n     if system:\n         username, group_name, home_dir = _system_service_identity(run_as_user)","@@ -703,9 +769,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)\n Environment=\"HERMES_HOME={hermes_home}\"\n Restart=on-failure\n RestartSec=30\n+RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\n KillMode=mixed\n KillSignal=SIGTERM\n-TimeoutStopSec=60\n+ExecReload=/bin/kill -USR1 $MAINPID\n+TimeoutStopSec={restart_timeout}\n StandardOutput=journal\n StandardError=journal\n ","@@ -733,9 +801,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)\n Environment=\"HERMES_HOME={hermes_home}\"\n Restart=on-failure\n RestartSec=30\n+RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\n KillMode=mixed\n KillSignal=SIGTERM\n-TimeoutStopSec=60\n+ExecReload=/bin/kill -USR1 $MAINPID\n+TimeoutStopSec={restart_timeout}\n StandardOutput=journal\n StandardError=journal\n ","@@ -838,6 +908,20 @@ def _select_systemd_scope(system: bool = False) -> bool:\n     return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists()\n \n \n+def _get_restart_drain_timeout() -> float:\n+    \"\"\"Return the configured gateway restart drain timeout in seconds.\"\"\"\n+    raw = os.getenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", \"\").strip()\n+    if not raw:\n+        cfg = read_raw_config()\n+        agent_cfg = cfg.get(\"agent\", {}) if isinstance(cfg, dict) else {}\n+        raw = str(\n+            agent_cfg.get(\n+                \"restart_drain_timeout\", DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+            )\n+        )\n+    return parse_restart_drain_timeout(raw)\n+\n+\n def systemd_install(force: bool = False, system: bool = False, run_as_user: str | None = None):\n     if system:\n         _require_root_for_system_service(\"install\")","@@ -923,7 +1007,13 @@ def systemd_restart(system: bool = False):\n     if system:\n         _require_root_for_system_service(\"restart\")\n     refresh_systemd_unit_if_needed(system=system)\n-    subprocess.run(_systemctl_cmd(system) + [\"restart\", get_service_name()], check=True, timeout=90)\n+    from gateway.status import get_running_pid\n+\n+    pid = get_running_pid()\n+    if pid is not None and _request_gateway_self_restart(pid):\n+        print(f\"✓ {_service_scope_label(system).capitalize()} service restart requested\")\n+        return\n+    subprocess.run(_systemctl_cmd(system) + [\"reload-or-restart\", get_service_name()], check=True, timeout=90)\n     print(f\"✓ {_service_scope_label(system).capitalize()} service restarted\")\n \n ","@@ -1211,7 +1301,7 @@ def launchd_stop():\n     _wait_for_gateway_exit(timeout=10.0, force_after=5.0)\n     print(\"✓ Service stopped\")\n \n-def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):\n+def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.0) -> bool:\n     \"\"\"Wait for the gateway process (by saved PID) to exit.\n \n     Uses the PID from the gateway.pid file — not launchd labels — so this","@@ -1226,21 +1316,21 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):\n     from gateway.status import get_running_pid\n \n     deadline = time.monotonic() + timeout\n-    force_deadline = time.monotonic() + force_after\n+    force_deadline = (time.monotonic() + force_after) if force_after is not None else None\n     force_sent = False\n \n     while time.monotonic() < deadline:\n         pid = get_running_pid()\n         if pid is None:\n-            return  # Process exited cleanly.\n+            return True  # Process exited cleanly.\n \n-        if not force_sent and time.monotonic() >= force_deadline:\n+        if force_after is not None and not force_sent and time.monotonic() >= force_deadline:\n             # Grace period expired — force-kill the specific PID.\n             try:\n                 terminate_pid(pid, force=True)\n                 print(f\"⚠ Gateway PID {pid} did not exit gracefully; sent SIGKILL\")\n             except (ProcessLookupError, PermissionError, OSError):\n-                return  # Already gone or we can't touch it.\n+                return True  # Already gone or we can't touch it.\n             force_sent = True\n \n         time.sleep(0.3)","@@ -1249,15 +1339,30 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):\n     remaining_pid = get_running_pid()\n     if remaining_pid is not None:\n         print(f\"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail\")\n+        return False\n+    return True\n \n \n def launchd_restart():\n     label = get_launchd_label()\n     target = f\"{_launchd_domain()}/{label}\"\n-    # Use kickstart -k so launchd performs an atomic kill+restart.\n-    # A two-step stop/start from inside the gateway's own process tree\n-    # would kill the shell before the start command is reached.\n+    drain_timeout = _get_restart_drain_timeout()\n+    from gateway.status import get_running_pid\n+\n     try:\n+        pid = get_running_pid()\n+        if pid is not None and _request_gateway_self_restart(pid):\n+            print(\"✓ Service restart requested\")\n+            return\n+        if pid is not None:\n+            try:\n+                terminate_pid(pid, force=False)\n+            except (ProcessLookupError, PermissionError, OSError):\n+                pid = None\n+            if pid is not None:\n+                exited = _wait_for_gateway_exit(timeout=drain_timeout, force_after=None)\n+                if not exited:\n+                    print(f\"⚠ Gateway drain timed out after {drain_timeout:.0f}s — forcing launchd restart\")\n         subprocess.run([\"launchctl\", \"kickstart\", \"-k\", target], check=True, timeout=90)\n         print(\"✓ Service restarted\")\n     except subprocess.CalledProcessError as e:","@@ -1728,6 +1833,8 @@ def _runtime_health_lines() -> list[str]:\n     lines: list[str] = []\n     gateway_state = state.get(\"gateway_state\")\n     exit_reason = state.get(\"exit_reason\")\n+    active_agents = state.get(\"active_agents\")\n+    restart_requested = state.get(\"restart_requested\")\n     platforms = state.get(\"platforms\", {}) or {}\n \n     for platform, pdata in platforms.items():","@@ -1737,6 +1844,10 @@ def _runtime_health_lines() -> list[str]:\n \n     if gateway_state == \"startup_failed\" and exit_reason:\n         lines.append(f\"⚠ Last startup issue: {exit_reason}\")\n+    elif gateway_state == \"draining\":\n+        action = \"restart\" if restart_requested else \"shutdown\"\n+        count = int(active_agents or 0)\n+        lines.append(f\"⏳ Gateway draining for {action} ({count} active agent(s))\")\n     elif gateway_state == \"stopped\" and exit_reason:\n         lines.append(f\"⚠ Last shutdown reason: {exit_reason}\")\n "]},{"file":"tests/gateway/restart_test_helpers.py","hunks":["@@ -0,0 +1,110 @@\n+import asyncio\n+from unittest.mock import AsyncMock, MagicMock\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult\n+from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+from gateway.run import GatewayRunner\n+from gateway.session import SessionSource\n+\n+\n+class RestartTestAdapter(BasePlatformAdapter):\n+    def __init__(self):\n+        super().__init__(PlatformConfig(enabled=True, token=\"***\"), Platform.TELEGRAM)\n+        self.sent: list[str] = []\n+\n+    async def connect(self):\n+        return True\n+\n+    async def disconnect(self):\n+        return None\n+\n+    async def send(self, chat_id, content, reply_to=None, metadata=None):\n+        self.sent.append(content)\n+        return SendResult(success=True, message_id=\"1\")\n+\n+    async def send_typing(self, chat_id, metadata=None):\n+        return None\n+\n+    async def get_chat_info(self, chat_id):\n+        return {\"id\": chat_id}\n+\n+\n+def make_restart_source(chat_id: str = \"123456\", chat_type: str = \"dm\") -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=chat_id,\n+        chat_type=chat_type,\n+    )\n+\n+\n+def make_restart_runner(\n+    adapter: BasePlatformAdapter | None = None,\n+) -> tuple[GatewayRunner, BasePlatformAdapter]:\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")}\n+    )\n+    runner._running = True\n+    runner._shutdown_event = asyncio.Event()\n+    runner._exit_reason = None\n+    runner._exit_code = None\n+    runner._running_agents = {}\n+    runner._running_agents_ts = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._pending_model_notes = {}\n+    runner._background_tasks = set()\n+    runner._draining = False\n+    runner._restart_requested = False\n+    runner._restart_task_started = False\n+    runner._restart_detached = False\n+    runner._restart_via_service = False\n+    runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+    runner._stop_task = None\n+    runner._busy_input_mode = \"interrupt\"\n+    runner._update_prompt_pending = {}\n+    runner._voice_mode = {}\n+    runner._session_model_overrides = {}\n+    runner._shutdown_all_gateway_honcho = lambda: None\n+    runner._update_runtime_status = MagicMock()\n+    runner._queue_or_replace_pending_event = GatewayRunner._queue_or_replace_pending_event.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._session_key_for_source = GatewayRunner._session_key_for_source.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._handle_active_session_busy_message = (\n+        GatewayRunner._handle_active_session_busy_message.__get__(runner, GatewayRunner)\n+    )\n+    runner._handle_restart_command = GatewayRunner._handle_restart_command.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._status_action_label = GatewayRunner._status_action_label.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._status_action_gerund = GatewayRunner._status_action_gerund.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._queue_during_drain_enabled = GatewayRunner._queue_during_drain_enabled.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._running_agent_count = GatewayRunner._running_agent_count.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__(\n+        runner, GatewayRunner\n+    )\n+    runner.request_restart = GatewayRunner.request_restart.__get__(runner, GatewayRunner)\n+    runner._is_user_authorized = lambda _source: True\n+    runner.hooks = MagicMock()\n+    runner.hooks.emit = AsyncMock()\n+    runner.pairing_store = MagicMock()\n+    runner.session_store = MagicMock()\n+    runner.delivery_router = MagicMock()\n+\n+    platform_adapter = adapter or RestartTestAdapter()\n+    platform_adapter.set_message_handler(AsyncMock(return_value=None))\n+    platform_adapter.set_busy_session_handler(runner._handle_active_session_busy_message)\n+    runner.adapters = {Platform.TELEGRAM: platform_adapter}\n+    return runner, platform_adapter"]},{"file":"tests/gateway/test_gateway_shutdown.py","hunks":["@@ -3,43 +3,15 @@\n \n import pytest\n \n-from gateway.config import GatewayConfig, Platform, PlatformConfig\n-from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult\n-from gateway.run import GatewayRunner\n-from gateway.session import SessionSource, build_session_key\n-\n-\n-class StubAdapter(BasePlatformAdapter):\n-    def __init__(self):\n-        super().__init__(PlatformConfig(enabled=True, token=\"***\"), Platform.TELEGRAM)\n-\n-    async def connect(self):\n-        return True\n-\n-    async def disconnect(self):\n-        return None\n-\n-    async def send(self, chat_id, content, reply_to=None, metadata=None):\n-        return SendResult(success=True, message_id=\"1\")\n-\n-    async def send_typing(self, chat_id, metadata=None):\n-        return None\n-\n-    async def get_chat_info(self, chat_id):\n-        return {\"id\": chat_id}\n-\n-\n-def _source(chat_id=\"123456\", chat_type=\"dm\"):\n-    return SessionSource(\n-        platform=Platform.TELEGRAM,\n-        chat_id=chat_id,\n-        chat_type=chat_type,\n-    )\n+from gateway.platforms.base import MessageEvent\n+from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE\n+from gateway.session import build_session_key\n+from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source\n \n \n @pytest.mark.asyncio\n async def test_cancel_background_tasks_cancels_inflight_message_processing():\n-    adapter = StubAdapter()\n+    _runner, adapter = make_restart_runner()\n     release = asyncio.Event()\n \n     async def block_forever(_event):","@@ -47,7 +19,7 @@ async def block_forever(_event):\n         return None\n \n     adapter.set_message_handler(block_forever)\n-    event = MessageEvent(text=\"work\", source=_source(), message_id=\"1\")\n+    event = MessageEvent(text=\"work\", source=make_restart_source(), message_id=\"1\")\n \n     await adapter.handle_message(event)\n     await asyncio.sleep(0)","@@ -65,17 +37,11 @@ async def block_forever(_event):\n \n @pytest.mark.asyncio\n async def test_gateway_stop_interrupts_running_agents_and_cancels_adapter_tasks():\n-    runner = object.__new__(GatewayRunner)\n-    runner.config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")})\n-    runner._running = True\n-    runner._shutdown_event = asyncio.Event()\n-    runner._exit_reason = None\n+    runner, adapter = make_restart_runner()\n     runner._pending_messages = {\"session\": \"pending text\"}\n     runner._pending_approvals = {\"session\": {\"command\": \"rm -rf /tmp/x\"}}\n-    runner._background_tasks = set()\n-    runner._shutdown_all_gateway_honcho = lambda: None\n+    runner._restart_drain_timeout = 0.0\n \n-    adapter = StubAdapter()\n     release = asyncio.Event()\n \n     async def block_forever(_event):","@@ -83,7 +49,7 @@ async def block_forever(_event):\n         return None\n \n     adapter.set_message_handler(block_forever)\n-    event = MessageEvent(text=\"work\", source=_source(), message_id=\"1\")\n+    event = MessageEvent(text=\"work\", source=make_restart_source(), message_id=\"1\")\n     await adapter.handle_message(event)\n     await asyncio.sleep(0)\n ","@@ -93,7 +59,6 @@ async def block_forever(_event):\n     session_key = build_session_key(event.source)\n     running_agent = MagicMock()\n     runner._running_agents = {session_key: running_agent}\n-    runner.adapters = {Platform.TELEGRAM: adapter}\n \n     with patch(\"gateway.status.remove_pid_file\"), patch(\"gateway.status.write_runtime_status\"):\n         await runner.stop()","@@ -105,3 +70,78 @@ async def block_forever(_event):\n     assert runner._pending_messages == {}\n     assert runner._pending_approvals == {}\n     assert runner._shutdown_event.is_set() is True\n+\n+\n[redacted:email]\n+async def test_gateway_stop_drains_running_agents_before_disconnect():\n+    runner, adapter = make_restart_runner()\n+    disconnect_mock = AsyncMock()\n+    adapter.disconnect = disconnect_mock\n+\n+    running_agent = MagicMock()\n+    runner._running_agents = {\"session\": running_agent}\n+\n+    async def finish_agent():\n+        await asyncio.sleep(0.05)\n+        runner._running_agents.clear()\n+\n+    asyncio.create_task(finish_agent())\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\"gateway.status.write_runtime_status\"):\n+        await runner.stop()\n+\n+    running_agent.interrupt.assert_not_called()\n+    disconnect_mock.assert_awaited_once()\n+    assert runner._shutdown_event.is_set() is True\n+\n+\n[redacted:email]\n+async def test_gateway_stop_interrupts_after_drain_timeout():\n+    runner, adapter = make_restart_runner()\n+    runner._restart_drain_timeout = 0.05\n+\n+    disconnect_mock = AsyncMock()\n+    adapter.disconnect = disconnect_mock\n+\n+    running_agent = MagicMock()\n+    runner._running_agents = {\"session\": running_agent}\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\"gateway.status.write_runtime_status\"):\n+        await runner.stop()\n+\n+    running_agent.interrupt.assert_called_once_with(\"Gateway shutting down\")\n+    disconnect_mock.assert_awaited_once()\n+    assert runner._shutdown_event.is_set() is True\n+\n+\n[redacted:email]\n+async def test_gateway_stop_service_restart_sets_named_exit_code():\n+    runner, adapter = make_restart_runner()\n+    adapter.disconnect = AsyncMock()\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\"gateway.status.write_runtime_status\"):\n+        await runner.stop(restart=True, service_restart=True)\n+\n+    assert runner._exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE\n+\n+\n[redacted:email]\n+async def test_drain_active_agents_throttles_status_updates():\n+    runner, _adapter = make_restart_runner()\n+    runner._update_runtime_status = MagicMock()\n+\n+    runner._running_agents = {\"a\": MagicMock(), \"b\": MagicMock()}\n+\n+    async def finish_agents():\n+        await asyncio.sleep(0.12)\n+        runner._running_agents.pop(\"a\")\n+        await asyncio.sleep(0.12)\n+        runner._running_agents.clear()\n+\n+    task = asyncio.create_task(finish_agents())\n+    await runner._drain_active_agents(1.0)\n+    await task\n+\n+    # Start, one count-change update, and final update. Allow one extra update\n+    # if the loop observes the zero-agent state before exiting.\n+    assert 3 <= runner._update_runtime_status.call_count <= 4"]},{"file":"tests/gateway/test_restart_drain.py","hunks":["@@ -0,0 +1,160 @@\n+import asyncio\n+import shutil\n+import subprocess\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+import gateway.run as gateway_run\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+from gateway.session import build_session_key\n+from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source\n+\n+\n[redacted:email]\n+async def test_restart_command_while_busy_requests_drain_without_interrupt():\n+    runner, _adapter = make_restart_runner()\n+    runner.request_restart = MagicMock(return_value=True)\n+    event = MessageEvent(\n+        text=\"/restart\",\n+        message_type=MessageType.TEXT,\n+        source=make_restart_source(),\n+        message_id=\"m1\",\n+    )\n+    session_key = build_session_key(event.source)\n+    running_agent = MagicMock()\n+    runner._running_agents[session_key] = running_agent\n+\n+    result = await runner._handle_message(event)\n+\n+    assert result == \"⏳ Draining 1 active agent(s) before restart...\"\n+    running_agent.interrupt.assert_not_called()\n+    runner.request_restart.assert_called_once_with(detached=True, via_service=False)\n+\n+\n[redacted:email]\n+async def test_drain_queue_mode_queues_follow_up_without_interrupt():\n+    runner, adapter = make_restart_runner()\n+    runner._draining = True\n+    runner._restart_requested = True\n+    runner._busy_input_mode = \"queue\"\n+\n+    event = MessageEvent(\n+        text=\"follow up\",\n+        message_type=MessageType.TEXT,\n+        source=make_restart_source(),\n+        message_id=\"m2\",\n+    )\n+    session_key = build_session_key(event.source)\n+    adapter._active_sessions[session_key] = asyncio.Event()\n+\n+    await adapter.handle_message(event)\n+\n+    assert session_key in adapter._pending_messages\n+    assert adapter._pending_messages[session_key].text == \"follow up\"\n+    assert not adapter._active_sessions[session_key].is_set()\n+    assert any(\"queued for the next turn\" in message for message in adapter.sent)\n+\n+\n[redacted:email]\n+async def test_draining_rejects_new_session_messages():\n+    runner, _adapter = make_restart_runner()\n+    runner._draining = True\n+    runner._restart_requested = True\n+\n+    event = MessageEvent(\n+        text=\"hello\",\n+        message_type=MessageType.TEXT,\n+        source=make_restart_source(\"fresh\"),\n+        message_id=\"m3\",\n+    )\n+\n+    result = await runner._handle_message(event)\n+\n+    assert result == \"⏳ Gateway is restarting and is not accepting new work right now.\"\n+\n+\n+def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, monkeypatch):\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.delenv(\"HERMES_GATEWAY_BUSY_INPUT_MODE\", raising=False)\n+\n+    assert gateway_run.GatewayRunner._load_busy_input_mode() == \"interrupt\"\n+\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"display:\\n  busy_input_mode: queue\\n\", encoding=\"utf-8\"\n+    )\n+    assert gateway_run.GatewayRunner._load_busy_input_mode() == \"queue\"\n+\n+    monkeypatch.setenv(\"HERMES_GATEWAY_BUSY_INPUT_MODE\", \"interrupt\")\n+    assert gateway_run.GatewayRunner._load_busy_input_mode() == \"interrupt\"\n+\n+\n+def test_load_restart_drain_timeout_prefers_env_then_config_then_default(\n+    tmp_path, monkeypatch, caplog\n+):\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.delenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", raising=False)\n+\n+    assert (\n+        gateway_run.GatewayRunner._load_restart_drain_timeout()\n+        == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+    )\n+\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"agent:\\n  restart_drain_timeout: 12\\n\", encoding=\"utf-8\"\n+    )\n+    assert gateway_run.GatewayRunner._load_restart_drain_timeout() == 12.0\n+\n+    monkeypatch.setenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", \"7\")\n+    assert gateway_run.GatewayRunner._load_restart_drain_timeout() == 7.0\n+\n+    monkeypatch.setenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", \"invalid\")\n+    assert (\n+        gateway_run.GatewayRunner._load_restart_drain_timeout()\n+        == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+    )\n+    assert \"Invalid restart_drain_timeout\" in caplog.text\n+\n+\n[redacted:email]\n+async def test_request_restart_is_idempotent():\n+    runner, _adapter = make_restart_runner()\n+    runner.stop = AsyncMock()\n+\n+    assert runner.request_restart(detached=True, via_service=False) is True\n+    first_task = next(iter(runner._background_tasks))\n+    assert runner.request_restart(detached=True, via_service=False) is False\n+\n+    await first_task\n+\n+    runner.stop.assert_awaited_once_with(\n+        restart=True, detached_restart=True, service_restart=False\n+    )\n+\n+\n[redacted:email]\n+async def test_launch_detached_restart_command_uses_setsid(monkeypatch):\n+    runner, _adapter = make_restart_runner()\n+    popen_calls = []\n+\n+    monkeypatch.setattr(gateway_run, \"_resolve_hermes_bin\", lambda: [\"/usr/bin/hermes\"])\n+    monkeypatch.setattr(gateway_run.os, \"getpid\", lambda: 321)\n+    monkeypatch.setattr(shutil, \"which\", lambda cmd: \"/usr/bin/setsid\" if cmd == \"setsid\" else None)\n+\n+    def fake_popen(cmd, **kwargs):\n+        popen_calls.append((cmd, kwargs))\n+        return MagicMock()\n+\n+    monkeypatch.setattr(subprocess, \"Popen\", fake_popen)\n+\n+    await runner._launch_detached_restart_command()\n+\n+    assert len(popen_calls) == 1\n+    cmd, kwargs = popen_calls[0]\n+    assert cmd[:2] == [\"/usr/bin/setsid\", \"bash\"]\n+    assert \"gateway restart\" in cmd[-1]\n+    assert \"kill -0 321\" in cmd[-1]\n+    assert kwargs[\"start_new_session\"] is True\n+    assert kwargs[\"stdout\"] is subprocess.DEVNULL\n+    assert kwargs[\"stderr\"] is subprocess.DEVNULL"]},{"file":"tests/gateway/test_session_boundary_hooks.py","hunks":["@@ -127,6 +127,16 @@ async def test_shutdown_fires_finalize_for_active_agents(mock_invoke_hook):\n     runner._shutdown_event = MagicMock()\n     runner.adapters = {}\n     runner._exit_reason = \"test\"\n+    runner._exit_code = None\n+    runner._draining = False\n+    runner._restart_requested = False\n+    runner._restart_task_started = False\n+    runner._restart_detached = False\n+    runner._restart_via_service = False\n+    runner._restart_drain_timeout = 0.0\n+    runner._stop_task = None\n+    runner._running_agents_ts = {}\n+    runner._update_runtime_status = MagicMock()\n \n     agent1 = MagicMock()\n     agent1.session_id = \"sess-a\""]},{"file":"tests/gateway/test_session_race_guard.py","hunks":["@@ -41,6 +41,15 @@ def _make_runner():\n     runner._pending_approvals = {}\n     runner._voice_mode = {}\n     runner._background_tasks = set()\n+    runner._draining = False\n+    runner._restart_requested = False\n+    runner._restart_task_started = False\n+    runner._restart_detached = False\n+    runner._restart_via_service = False\n+    runner._restart_drain_timeout = 0.0\n+    runner._stop_task = None\n+    runner._exit_code = None\n+    runner._update_runtime_status = MagicMock()\n     runner._is_user_authorized = lambda _source: True\n     runner.hooks = MagicMock()\n     runner.hooks.emit = AsyncMock()"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -5,6 +5,10 @@\n from types import SimpleNamespace\n \n import hermes_cli.gateway as gateway_cli\n+from gateway.restart import (\n+    DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,\n+    GATEWAY_SERVICE_RESTART_EXIT_CODE,\n+)\n \n \n class TestSystemdServiceRefresh:","@@ -74,7 +78,7 @@ def fake_run(cmd, check=True, **kwargs):\n         assert unit_path.read_text(encoding=\"utf-8\") == \"new unit\\n\"\n         assert calls[:2] == [\n             [\"systemctl\", \"--user\", \"daemon-reload\"],\n-            [\"systemctl\", \"--user\", \"restart\", gateway_cli.get_service_name()],\n+            [\"systemctl\", \"--user\", \"reload-or-restart\", gateway_cli.get_service_name()],\n         ]\n \n ","@@ -84,6 +88,8 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self\n \n         assert \"ExecStart=\" in unit\n         assert \"ExecStop=\" not in unit\n+        assert \"ExecReload=/bin/kill -USR1 $MAINPID\" in unit\n+        assert f\"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\" in unit\n         assert \"TimeoutStopSec=60\" in unit\n \n     def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):","@@ -98,6 +104,8 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se\n \n         assert \"ExecStart=\" in unit\n         assert \"ExecStop=\" not in unit\n+        assert \"ExecReload=/bin/kill -USR1 $MAINPID\" in unit\n+        assert f\"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\" in unit\n         assert \"TimeoutStopSec=60\" in unit\n         assert \"WantedBy=multi-user.target\" in unit\n ","@@ -157,6 +165,31 @@ def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch):\n \n \n class TestLaunchdServiceRecovery:\n+    def test_get_restart_drain_timeout_prefers_env_then_config_then_default(self, monkeypatch):\n+        monkeypatch.delenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", raising=False)\n+        monkeypatch.setattr(gateway_cli, \"read_raw_config\", lambda: {})\n+\n+        assert (\n+            gateway_cli._get_restart_drain_timeout()\n+            == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+        )\n+\n+        monkeypatch.setattr(\n+            gateway_cli,\n+            \"read_raw_config\",\n+            lambda: {\"agent\": {\"restart_drain_timeout\": 14}},\n+        )\n+        assert gateway_cli._get_restart_drain_timeout() == 14.0\n+\n+        monkeypatch.setenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", \"9\")\n+        assert gateway_cli._get_restart_drain_timeout() == 9.0\n+\n+        monkeypatch.setenv(\"HERMES_RESTART_DRAIN_TIMEOUT\", \"invalid\")\n+        assert (\n+            gateway_cli._get_restart_drain_timeout()\n+            == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT\n+        )\n+\n     def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch):\n         plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n         plist_path.write_text(\"<plist>old content</plist>\", encoding=\"utf-8\")","@@ -234,6 +267,55 @@ def fake_run(cmd, check=False, **kwargs):\n             [\"launchctl\", \"kickstart\", target],\n         ]\n \n+    def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypatch):\n+        calls = []\n+        target = f\"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}\"\n+\n+        monkeypatch.setattr(gateway_cli, \"_get_restart_drain_timeout\", lambda: 12.0)\n+        monkeypatch.setattr(gateway_cli, \"_request_gateway_self_restart\", lambda pid: False)\n+        monkeypatch.setattr(gateway_cli, \"_wait_for_gateway_exit\", lambda timeout, force_after=None: True)\n+        monkeypatch.setattr(gateway_cli, \"terminate_pid\", lambda pid, force=False: calls.append((\"term\", pid, force)))\n+        monkeypatch.setattr(\n+            \"gateway.status.get_running_pid\",\n+            lambda: 321,\n+        )\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            calls.append(cmd)\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        gateway_cli.launchd_restart()\n+\n+        assert calls == [\n+            (\"term\", 321, False),\n+            [\"launchctl\", \"kickstart\", \"-k\", target],\n+        ]\n+\n+    def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, monkeypatch, capsys):\n+        calls = []\n+\n+        monkeypatch.setattr(\n+            \"gateway.status.get_running_pid\",\n+            lambda: 321,\n+        )\n+        monkeypatch.setattr(\n+            gateway_cli,\n+            \"_request_gateway_self_restart\",\n+            lambda pid: calls.append((\"self\", pid)) or True,\n+        )\n+        monkeypatch.setattr(\n+            gateway_cli.subprocess,\n+            \"run\",\n+            lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError(\"launchctl should not run\")),\n+        )\n+\n+        gateway_cli.launchd_restart()\n+\n+        assert calls == [(\"self\", 321)]\n+        assert \"restart requested\" in capsys.readouterr().out.lower()\n+\n     def test_launchd_stop_uses_bootout_not_kill(self, monkeypatch):\n         \"\"\"launchd_stop must bootout the service so KeepAlive doesn't respawn it.\"\"\"\n         label = gateway_cli.get_launchd_label()","@@ -337,6 +419,31 @@ def fake_run(cmd, capture_output=True, text=True, **kwargs):\n \n \n class TestGatewaySystemServiceRouting:\n+    def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys):\n+        calls = []\n+\n+        monkeypatch.setattr(gateway_cli, \"_select_systemd_scope\", lambda system=False: False)\n+        monkeypatch.setattr(gateway_cli, \"refresh_systemd_unit_if_needed\", lambda system=False: calls.append((\"refresh\", system)))\n+        monkeypatch.setattr(\n+            \"gateway.status.get_running_pid\",\n+            lambda: 654,\n+        )\n+        monkeypatch.setattr(\n+            gateway_cli,\n+            \"_request_gateway_self_restart\",\n+            lambda pid: calls.append((\"self\", pid)) or True,\n+        )\n+        monkeypatch.setattr(\n+            gateway_cli.subprocess,\n+            \"run\",\n+            lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError(\"systemctl should not run\")),\n+        )\n+\n+        gateway_cli.systemd_restart()\n+\n+        assert calls == [(\"refresh\", False), (\"self\", 654)]\n+        assert \"restart requested\" in capsys.readouterr().out.lower()\n+\n     def test_gateway_install_passes_system_flags(self, monkeypatch):\n         monkeypatch.setattr(gateway_cli, \"supports_systemd_services\", lambda: True)\n         monkeypatch.setattr(gateway_cli, \"is_termux\", lambda: False)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}