{"repo":"NousResearch/hermes-agent","public_id":"1049","display_label":"Gateway /update fallback when hermes is not on PATH","summary":"One issue reports that the gateway /update command errors with a 'command not found on PATH' message even while Hermes is running. The other three members are pull requests that propose falling back to the module entrypoint (e.g. sys.executable -m hermes_cli.main) when the hermes executable is not on PATH; two of them share an identical title.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1355,"merged_at":"2026-03-15T01:05:28+00:00"},"resolution":{"state":"resolved","by":"pr-1355"},"freshness":{"state":"stale","last_verified_at":"2026-03-15T01:05:28+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":1049,"pr_number":1355,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-15T01:05:28+00:00"}],"members":[{"kind":"issue","number":1049,"title":"bug(gateway): /update hard-fails with ' command not found on PATH' even when Hermes is running","url":null,"state":"closed"},{"kind":"pr","number":1052,"title":"fix(gateway): fall back to sys.executable -m hermes_cli.main when hermes not on PATH","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1355","number":1355}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1150,"title":"fix(gateway): fall back to module entrypoint when hermes is not on PATH","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":1355,"title":"fix(gateway): fall back to module entrypoint when hermes is not on PATH","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_update_command.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -215,6 +215,33 @@ def _resolve_gateway_model() -> str:\n     return model\n \n \n+def _resolve_hermes_bin() -> Optional[list[str]]:\n+    \"\"\"Resolve the Hermes update command as argv parts.\n+\n+    Tries in order:\n+    1. ``shutil.which(\"hermes\")`` — standard PATH lookup\n+    2. ``sys.executable -m hermes_cli.main`` — fallback when Hermes is running\n+       from a venv/module invocation and the ``hermes`` shim is not on PATH\n+\n+    Returns argv parts ready for quoting/joining, or ``None`` if neither works.\n+    \"\"\"\n+    import shutil\n+\n+    hermes_bin = shutil.which(\"hermes\")\n+    if hermes_bin:\n+        return [hermes_bin]\n+\n+    try:\n+        import importlib.util\n+\n+        if importlib.util.find_spec(\"hermes_cli\") is not None:\n+            return [sys.executable, \"-m\", \"hermes_cli.main\"]\n+    except Exception:\n+        pass\n+\n+    return None\n+\n+\n class GatewayRunner:\n     \"\"\"\n     Main gateway controller.","@@ -245,6 +272,8 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n         self.delivery_router = DeliveryRouter(self.config)\n         self._running = False\n         self._shutdown_event = asyncio.Event()\n+        self._exit_cleanly = False\n+        self._exit_reason: Optional[str] = None\n         \n         # Track running agents per session for interrupt support\n         # Key: session_key, Value: AIAgent instance","@@ -463,6 +492,41 @@ async def _async_flush_memories(self, old_session_id: str):\n         \"\"\"Run the sync memory flush in a thread pool so it won't block the event loop.\"\"\"\n         loop = asyncio.get_event_loop()\n         await loop.run_in_executor(None, self._flush_memories_for_session, old_session_id)\n+\n+    @property\n+    def should_exit_cleanly(self) -> bool:\n+        return self._exit_cleanly\n+\n+    @property\n+    def exit_reason(self) -> Optional[str]:\n+        return self._exit_reason\n+\n+    async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:\n+        \"\"\"React to a non-retryable adapter failure after startup.\"\"\"\n+        logger.error(\n+            \"Fatal %s adapter error (%s): %s\",\n+            adapter.platform.value,\n+            adapter.fatal_error_code or \"unknown\",\n+            adapter.fatal_error_message or \"unknown error\",\n+        )\n+\n+        existing = self.adapters.get(adapter.platform)\n+        if existing is adapter:\n+            try:\n+                await adapter.disconnect()\n+            finally:\n+                self.adapters.pop(adapter.platform, None)\n+                self.delivery_router.adapters = self.adapters\n+\n+        if not self.adapters:\n+            self._exit_reason = adapter.fatal_error_message or \"All messaging adapters disconnected\"\n+            logger.error(\"No connected messaging platforms remain. Shutting down gateway cleanly.\")\n+            await self.stop()\n+\n+    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     @staticmethod\n     def _load_prefill_messages() -> List[Dict[str, Any]]:","@@ -647,6 +711,11 @@ async def start(self) -> bool:\n         \"\"\"\n         logger.info(\"Starting Hermes Gateway...\")\n         logger.info(\"Session storage: %s\", self.config.sessions_dir)\n+        try:\n+            from gateway.status import write_runtime_status\n+            write_runtime_status(gateway_state=\"starting\", exit_reason=None)\n+        except Exception:\n+            pass\n         \n         # Warn if no user allowlists are configured and open access is not opted in\n         _any_allowlist = any(","@@ -676,6 +745,7 @@ async def start(self) -> bool:\n             logger.warning(\"Process checkpoint recovery: %s\", e)\n         \n         connected_count = 0\n+        startup_nonretryable_errors: list[str] = []\n         \n         # Initialize and connect each configured platform\n         for platform, platform_config in self.config.platforms.items():","@@ -687,8 +757,9 @@ async def start(self) -> bool:\n                 logger.warning(\"No adapter available for %s\", platform.value)\n                 continue\n             \n-            # Set up message handler\n+            # Set up message + fatal error handlers\n             adapter.set_message_handler(self._handle_message)\n+            adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)\n             \n             # Try to connect\n             logger.info(\"Connecting to %s...\", platform.value)","@@ -701,10 +772,24 @@ async def start(self) -> bool:\n                     logger.info(\"✓ %s connected\", platform.value)\n                 else:\n                     logger.warning(\"✗ %s failed to connect\", platform.value)\n+                    if adapter.has_fatal_error and not adapter.fatal_error_retryable:\n+                        startup_nonretryable_errors.append(\n+                            f\"{platform.value}: {adapter.fatal_error_message}\"\n+                        )\n             except Exception as e:\n                 logger.error(\"✗ %s error: %s\", platform.value, e)\n         \n         if connected_count == 0:\n+            if startup_nonretryable_errors:\n+                reason = \"; \".join(startup_nonretryable_errors)\n+                logger.error(\"Gateway hit a non-retryable startup conflict: %s\", reason)\n+                try:\n+                    from gateway.status import write_runtime_status\n+                    write_runtime_status(gateway_state=\"startup_failed\", exit_reason=reason)\n+                except Exception:\n+                    pass\n+                self._request_clean_exit(reason)\n+                return True\n             logger.warning(\"No messaging platforms connected.\")\n             logger.info(\"Gateway will continue running for cron job execution.\")\n         ","@@ -712,6 +797,11 @@ 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         \n         # Emit gateway:startup hook\n         hook_count = len(self.hooks.loaded_hooks)","@@ -806,8 +896,12 @@ async def stop(self) -> None:\n         self._shutdown_all_gateway_honcho()\n         self._shutdown_event.set()\n         \n-        from gateway.status import remove_pid_file\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     ","@@ -3155,9 +3249,14 @@ async def _handle_update_command(self, event: MessageEvent) -> str:\n         if not git_dir.exists():\n             return \"✗ Not a git repository — cannot update.\"\n \n-        hermes_bin = shutil.which(\"hermes\")\n-        if not hermes_bin:\n-            return \"✗ `hermes` command not found on PATH.\"\n+        hermes_cmd = _resolve_hermes_bin()\n+        if not hermes_cmd:\n+            return (\n+                \"✗ Could not locate the `hermes` command. \"\n+                \"Hermes is running, but the update command could not find the \"\n+                \"executable on PATH or via the current Python interpreter. \"\n+                \"Try running `hermes update` manually in your terminal.\"\n+            )\n \n         pending_path = _hermes_home / \".update_pending.json\"\n         output_path = _hermes_home / \".update_output.txt\"","@@ -3173,8 +3272,9 @@ async def _handle_update_command(self, event: MessageEvent) -> str:\n \n         # Spawn `hermes update` in a separate cgroup so it survives gateway\n         # restart. systemd-run --user --scope creates a transient scope unit.\n+        hermes_cmd_str = \" \".join(shlex.quote(part) for part in hermes_cmd)\n         update_cmd = (\n-            f\"{shlex.quote(hermes_bin)} update > {shlex.quote(str(output_path))} 2>&1; \"\n+            f\"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; \"\n             f\"status=$?; printf '%s' \\\"$status\\\" > {shlex.quote(str(exit_code_path))}\"\n         )\n         try:","@@ -4340,6 +4440,10 @@ def signal_handler():\n     success = await runner.start()\n     if not success:\n         return False\n+    if runner.should_exit_cleanly:\n+        if runner.exit_reason:\n+            logger.error(\"Gateway exiting cleanly: %s\", runner.exit_reason)\n+        return True\n     \n     # Write PID file so CLI can detect gateway is running\n     import atexit"]},{"file":"tests/gateway/test_update_command.py","hunks":["@@ -88,7 +88,7 @@ class FakePath(type(Path())):\n \n     @pytest.mark.asyncio\n     async def test_no_hermes_binary(self, tmp_path):\n-        \"\"\"Returns error when hermes is not on PATH.\"\"\"\n+        \"\"\"Returns error when hermes is not on PATH and hermes_cli is not importable.\"\"\"\n         runner = _make_runner()\n         event = _make_event()\n ","@@ -102,10 +102,77 @@ async def test_no_hermes_binary(self, tmp_path):\n \n         with patch(\"gateway.run._hermes_home\", tmp_path), \\\n              patch(\"gateway.run.__file__\", fake_file), \\\n-             patch(\"shutil.which\", return_value=None):\n+             patch(\"shutil.which\", return_value=None), \\\n+             patch(\"importlib.util.find_spec\", return_value=None):\n             result = await runner._handle_update_command(event)\n \n-        assert \"not found on PATH\" in result\n+        assert \"Could not locate\" in result\n+        assert \"hermes update\" in result\n+\n+    @pytest.mark.asyncio\n+    async def test_fallback_to_sys_executable(self, tmp_path):\n+        \"\"\"Falls back to sys.executable -m hermes_cli.main when hermes not on PATH.\"\"\"\n+        runner = _make_runner()\n+        event = _make_event()\n+\n+        fake_root = tmp_path / \"project\"\n+        fake_root.mkdir()\n+        (fake_root / \".git\").mkdir()\n+        (fake_root / \"gateway\").mkdir()\n+        (fake_root / \"gateway\" / \"run.py\").touch()\n+        fake_file = str(fake_root / \"gateway\" / \"run.py\")\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+\n+        mock_popen = MagicMock()\n+        fake_spec = MagicMock()\n+\n+        with patch(\"gateway.run._hermes_home\", hermes_home), \\\n+             patch(\"gateway.run.__file__\", fake_file), \\\n+             patch(\"shutil.which\", return_value=None), \\\n+             patch(\"importlib.util.find_spec\", return_value=fake_spec), \\\n+             patch(\"subprocess.Popen\", mock_popen):\n+            result = await runner._handle_update_command(event)\n+\n+        assert \"Starting Hermes update\" in result\n+        call_args = mock_popen.call_args[0][0]\n+        # The update_cmd uses sys.executable -m hermes_cli.main\n+        joined = \" \".join(call_args) if isinstance(call_args, list) else call_args\n+        assert \"hermes_cli.main\" in joined or \"bash\" in call_args[0]\n+\n+    @pytest.mark.asyncio\n+    async def test_resolve_hermes_bin_prefers_which(self, tmp_path):\n+        \"\"\"_resolve_hermes_bin returns argv parts from shutil.which when available.\"\"\"\n+        from gateway.run import _resolve_hermes_bin\n+\n+        with patch(\"shutil.which\", return_value=\"/custom/path/hermes\"):\n+            result = _resolve_hermes_bin()\n+\n+        assert result == [\"/custom/path/hermes\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_resolve_hermes_bin_fallback(self):\n+        \"\"\"_resolve_hermes_bin falls back to sys.executable argv when which fails.\"\"\"\n+        import sys\n+        from gateway.run import _resolve_hermes_bin\n+\n+        fake_spec = MagicMock()\n+        with patch(\"shutil.which\", return_value=None), \\\n+             patch(\"importlib.util.find_spec\", return_value=fake_spec):\n+            result = _resolve_hermes_bin()\n+\n+        assert result == [sys.executable, \"-m\", \"hermes_cli.main\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_resolve_hermes_bin_returns_none_when_both_fail(self):\n+        \"\"\"_resolve_hermes_bin returns None when both strategies fail.\"\"\"\n+        from gateway.run import _resolve_hermes_bin\n+\n+        with patch(\"shutil.which\", return_value=None), \\\n+             patch(\"importlib.util.find_spec\", return_value=None):\n+            result = _resolve_hermes_bin()\n+\n+        assert result is None\n \n     @pytest.mark.asyncio\n     async def test_writes_pending_marker(self, tmp_path):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}