NousResearch/hermes-agent

Gateway /update fallback when hermes is not on PATH

Cluster 1049 · gateway. Data as of

Nothing open on GitHub. Fix merged in #1355 on 15 Mar 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #1355. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #1355. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #1355 fix(gateway): fall back to module entrypoint when hermes is not on PATH

    merged to main

    files: gateway/run.py, tests/gateway/test_update_command.py

    Show the diff
    • gateway/run.py
      @@ -215,6 +215,33 @@ def _resolve_gateway_model() -> str:
           return model
       
       
      +def _resolve_hermes_bin() -> Optional[list[str]]:
      +    """Resolve the Hermes update command as argv parts.
      +
      +    Tries in order:
      +    1. ``shutil.which("hermes")`` — standard PATH lookup
      +    2. ``sys.executable -m hermes_cli.main`` — fallback when Hermes is running
      +       from a venv/module invocation and the ``hermes`` shim is not on PATH
      +
      +    Returns argv parts ready for quoting/joining, or ``None`` if neither works.
      +    """
      +    import shutil
      +
      +    hermes_bin = shutil.which("hermes")
      +    if hermes_bin:
      +        return [hermes_bin]
      +
      +    try:
      +        import importlib.util
      +
      +        if importlib.util.find_spec("hermes_cli") is not None:
      +            return [sys.executable, "-m", "hermes_cli.main"]
      +    except Exception:
      +        pass
      +
      +    return None
      +
      +
       class GatewayRunner:
           """
           Main gateway controller.
      @@ -245,6 +272,8 @@ def __init__(self, config: Optional[GatewayConfig] = None):
               self.delivery_router = DeliveryRouter(self.config)
               self._running = False
               self._shutdown_event = asyncio.Event()
      +        self._exit_cleanly = False
      +        self._exit_reason: Optional[str] = None
               
               # Track running agents per session for interrupt support
               # Key: session_key, Value: AIAgent instance
      @@ -463,6 +492,41 @@ async def _async_flush_memories(self, old_session_id: str):
               """Run the sync memory flush in a thread pool so it won't block the event loop."""
               loop = asyncio.get_event_loop()
               await loop.run_in_executor(None, self._flush_memories_for_session, old_session_id)
      +
      +    @property
      +    def should_exit_cleanly(self) -> bool:
      +        return self._exit_cleanly
      +
      +    @property
      +    def exit_reason(self) -> Optional[str]:
      +        return self._exit_reason
      +
      +    async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
      +        """React to a non-retryable adapter failure after startup."""
      +        logger.error(
      +            "Fatal %s adapter error (%s): %s",
      +            adapter.platform.value,
      +            adapter.fatal_error_code or "unknown",
      +            adapter.fatal_error_message or "unknown error",
      +        )
      +
      +        existing = self.adapters.get(adapter.platform)
      +        if existing is adapter:
      +            try:
      +                await adapter.disconnect()
      +            finally:
      +                self.adapters.pop(adapter.platform, None)
      +                self.delivery_router.adapters = self.adapters
      +
      +        if not self.adapters:
      +            self._exit_reason = adapter.fatal_error_message or "All messaging adapters disconnected"
      +            logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.")
      +            await self.stop()
      +
      +    def _request_clean_exit(self, reason: str) -> None:
      +        self._exit_cleanly = True
      +        self._exit_reason = reason
      +        self._shutdown_event.set()
           
           @staticmethod
           def _load_prefill_messages() -> List[Dict[str, Any]]:
      @@ -647,6 +711,11 @@ async def start(self) -> bool:
               """
               logger.info("Starting Hermes Gateway...")
               logger.info("Session storage: %s", self.config.sessions_dir)
      +        try:
      +            from gateway.status import write_runtime_status
      +            write_runtime_status(gateway_state="starting", exit_reason=None)
      +        except Exception:
      +            pass
               
               # Warn if no user allowlists are configured and open access is not opted in
               _any_allowlist = any(
      @@ -676,6 +745,7 @@ async def start(self) -> bool:
                   logger.warning("Process checkpoint recovery: %s", e)
               
               connected_count = 0
      +        startup_nonretryable_errors: list[str] = []
               
               # Initialize and connect each configured platform
               for platform, platform_config in self.config.platforms.items():
      @@ -687,8 +757,9 @@ async def start(self) -> bool:
                       logger.warning("No adapter available for %s", platform.value)
                       continue
                   
      -            # Set up message handler
      +            # Set up message + fatal error handlers
                   adapter.set_message_handler(self._handle_message)
      +            adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
                   
                   # Try to connect
                   logger.info("Connecting to %s...", platform.value)
      @@ -701,10 +772,24 @@ async def start(self) -> bool:
                           logger.info("✓ %s connected", platform.value)
                       else:
                           logger.warning("✗ %s failed to connect", platform.value)
      +                    if adapter.has_fatal_error and not adapter.fatal_error_retryable:
      +                        startup_nonretryable_errors.append(
      +                            f"{platform.value}: {adapter.fatal_error_message}"
      +                        )
                   except Exception as e:
                       logger.error("✗ %s error: %s", platform.value, e)
               
               if connected_count == 0:
      +            if startup_nonretryable_errors:
      +                reason = "; ".join(startup_nonretryable_errors)
      +                logger.error("Gateway hit a non-retryable startup conflict: %s", reason)
      +                try:
      +                    from gateway.status import write_runtime_status
      +                    write_runtime_status(gateway_state="startup_failed", exit_reason=reason)
      +                except Exception:
      +                    pass
      +                self._request_clean_exit(reason)
      +                return True
                   logger.warning("No messaging platforms connected.")
                   logger.info("Gateway will continue running for cron job execution.")
               
      @@ -712,6 +797,11 @@ async def start(self) -> bool:
               self.delivery_router.adapters = self.adapters
               
               self._running = True
      +        try:
      +            from gateway.status import write_runtime_status
      +            write_runtime_status(gateway_state="running", exit_reason=None)
      +        except Exception:
      +            pass
               
               # Emit gateway:startup hook
               hook_count = len(self.hooks.loaded_hooks)
      @@ -806,8 +896,12 @@ async def stop(self) -> None:
               self._shutdown_all_gateway_honcho()
               self._shutdown_event.set()
               
      -        from gateway.status import remove_pid_file
      +        from gateway.status import remove_pid_file, write_runtime_status
               remove_pid_file()
      +        try:
      +            write_runtime_status(gateway_state="stopped", exit_reason=self._exit_reason)
      +        except Exception:
      +            pass
               
               logger.info("Gateway stopped")
           
      @@ -3155,9 +3249,14 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
               if not git_dir.exists():
                   return "✗ Not a git repository — cannot update."
       
      -        hermes_bin = shutil.which("hermes")
      -        if not hermes_bin:
      -            return "✗ `hermes` command not found on PATH."
      +        hermes_cmd = _resolve_hermes_bin()
      +        if not hermes_cmd:
      +            return (
      +                "✗ Could not locate the `hermes` command. "
      +                "Hermes is running, but the update command could not find the "
      +                "executable on PATH or via the current Python interpreter. "
      +                "Try running `hermes update` manually in your terminal."
      +            )
       
               pending_path = _hermes_home / ".update_pending.json"
               output_path = _hermes_home / ".update_output.txt"
      @@ -3173,8 +3272,9 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
       
               # Spawn `hermes update` in a separate cgroup so it survives gateway
               # restart. systemd-run --user --scope creates a transient scope unit.
      +        hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd)
               update_cmd = (
      -            f"{shlex.quote(hermes_bin)} update > {shlex.quote(str(output_path))} 2>&1; "
      +            f"{hermes_cmd_str} update > {shlex.quote(str(output_path))} 2>&1; "
                   f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
               )
               try:
      @@ -4340,6 +4440,10 @@ def signal_handler():
           success = await runner.start()
           if not success:
               return False
      +    if runner.should_exit_cleanly:
      +        if runner.exit_reason:
      +            logger.error("Gateway exiting cleanly: %s", runner.exit_reason)
      +        return True
           
           # Write PID file so CLI can detect gateway is running
           import atexit
    • tests/gateway/test_update_command.py
      @@ -88,7 +88,7 @@ class FakePath(type(Path())):
       
           @pytest.mark.asyncio
           async def test_no_hermes_binary(self, tmp_path):
      -        """Returns error when hermes is not on PATH."""
      +        """Returns error when hermes is not on PATH and hermes_cli is not importable."""
               runner = _make_runner()
               event = _make_event()
       
      @@ -102,10 +102,77 @@ async def test_no_hermes_binary(self, tmp_path):
       
               with patch("gateway.run._hermes_home", tmp_path), \
                    patch("gateway.run.__file__", fake_file), \
      -             patch("shutil.which", return_value=None):
      +             patch("shutil.which", return_value=None), \
      +             patch("importlib.util.find_spec", return_value=None):
                   result = await runner._handle_update_command(event)
       
      -        assert "not found on PATH" in result
      +        assert "Could not locate" in result
      +        assert "hermes update" in result
      +
      +    @pytest.mark.asyncio
      +    async def test_fallback_to_sys_executable(self, tmp_path):
      +        """Falls back to sys.executable -m hermes_cli.main when hermes not on PATH."""
      +        runner = _make_runner()
      +        event = _make_event()
      +
      +        fake_root = tmp_path / "project"
      +        fake_root.mkdir()
      +        (fake_root / ".git").mkdir()
      +        (fake_root / "gateway").mkdir()
      +        (fake_root / "gateway" / "run.py").touch()
      +        fake_file = str(fake_root / "gateway" / "run.py")
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +
      +        mock_popen = MagicMock()
      +        fake_spec = MagicMock()
      +
      +        with patch("gateway.run._hermes_home", hermes_home), \
      +             patch("gateway.run.__file__", fake_file), \
      +             patch("shutil.which", return_value=None), \
      +             patch("importlib.util.find_spec", return_value=fake_spec), \
      +             patch("subprocess.Popen", mock_popen):
      +            result = await runner._handle_update_command(event)
      +
      +        assert "Starting Hermes update" in result
      +        call_args = mock_popen.call_args[0][0]
      +        # The update_cmd uses sys.executable -m hermes_cli.main
      +        joined = " ".join(call_args) if isinstance(call_args, list) else call_args
      +        assert "hermes_cli.main" in joined or "bash" in call_args[0]
      +
      +    @pytest.mark.asyncio
      +    async def test_resolve_hermes_bin_prefers_which(self, tmp_path):
      +        """_resolve_hermes_bin returns argv parts from shutil.which when available."""
      +        from gateway.run import _resolve_hermes_bin
      +
      +        with patch("shutil.which", return_value="/custom/path/hermes"):
      +            result = _resolve_hermes_bin()
      +
      +        assert result == ["/custom/path/hermes"]
      +
      +    @pytest.mark.asyncio
      +    async def test_resolve_hermes_bin_fallback(self):
      +        """_resolve_hermes_bin falls back to sys.executable argv when which fails."""
      +        import sys
      +        from gateway.run import _resolve_hermes_bin
      +
      +        fake_spec = MagicMock()
      +        with patch("shutil.which", return_value=None), \
      +             patch("importlib.util.find_spec", return_value=fake_spec):
      +            result = _resolve_hermes_bin()
      +
      +        assert result == [sys.executable, "-m", "hermes_cli.main"]
      +
      +    @pytest.mark.asyncio
      +    async def test_resolve_hermes_bin_returns_none_when_both_fail(self):
      +        """_resolve_hermes_bin returns None when both strategies fail."""
      +        from gateway.run import _resolve_hermes_bin
      +
      +        with patch("shutil.which", return_value=None), \
      +             patch("importlib.util.find_spec", return_value=None):
      +            result = _resolve_hermes_bin()
      +
      +        assert result is None
       
           @pytest.mark.asyncio
           async def test_writes_pending_marker(self, tmp_path):

2 PRs closed without merging

From the close comments: merged via another change 1 · other 1

  • #1052 fix(gateway): fall back to sys.executable -m hermes_cli.main when hermes not on PATH · merged via another change → #1355
  • #1150 fix(gateway): fall back to module entrypoint when hermes is not on PATH · other
Closed issues · 1
  • Closed#1049bug(gateway): /update hard-fails with ' command not found on PATH' even when Hermes is running
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#1355
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
15 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.