NousResearch/hermes-agent

Gateway restart and shutdown handling, including systemd-managed services

Cluster 8104. Data as of

Nothing open on GitHub. Fix merged in #9850 on 14 Apr 2026. Check it covers your case.

The short answer

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

Members concern gateway lifecycle behavior: one issue reports that the /restart slash command hardcodes detached=True and via_service=False, which is described as permanently killing a systemd-managed gateway. A related PR proposes detecting systemd in /restart, and another PR covers notifying active sessions on shutdown and updating a health check, which is adjacent rather than clearly the same problem.

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.

  • #9850 fix: notify active sessions on gateway shutdown + update health check

    merged to main

    files: gateway/run.py, hermes_cli/main.py, tests/gateway/restart_test_helpers.py, tests/gateway/test_restart_drain.py

    Show the diff
    • gateway/run.py
      @@ -1391,6 +1391,65 @@ def _interrupt_running_agents(self, reason: str) -> None:
                   except Exception as e:
                       logger.debug("Failed interrupting agent during shutdown: %s", e)
       
      +    async def _notify_active_sessions_of_shutdown(self) -> None:
      +        """Send a notification to every chat with an active agent.
      +
      +        Called at the very start of stop() — adapters are still connected so
      +        messages can be delivered.  Best-effort: individual send failures are
      +        logged and swallowed so they never block the shutdown sequence.
      +        """
      +        active = self._snapshot_running_agents()
      +        if not active:
      +            return
      +
      +        action = "restarting" if self._restart_requested else "shutting down"
      +        hint = (
      +            "Your current task will be interrupted. "
      +            "Use /retry after restart to continue."
      +            if self._restart_requested
      +            else "Your current task will be interrupted."
      +        )
      +        msg = f"⚠️ Gateway {action} — {hint}"
      +
      +        notified: set = set()
      +        for session_key in active:
      +            # Parse platform + chat_id from the session key.
      +            # Format: agent:main:{platform}:{chat_type}:{chat_id}[:{extra}...]
      +            parts = session_key.split(":")
      +            if len(parts) < 5:
      +                continue
      +            platform_str = parts[2]
      +            chat_id = parts[4]
      +
      +            # Deduplicate: one notification per chat, even if multiple
      +            # sessions (different users/threads) share the same chat.
      +            dedup_key = (platform_str, chat_id)
      +            if dedup_key in notified:
      +                continue
      +
      +            try:
      +                platform = Platform(platform_str)
      +                adapter = self.adapters.get(platform)
      +                if not adapter:
      +                    continue
      +
      +                # Include thread_id if present so the message lands in the
      +                # correct forum topic / thread.
      +                thread_id = parts[5] if len(parts) > 5 else None
      +                metadata = {"thread_id": thread_id} if thread_id else None
      +
      +                await adapter.send(chat_id, msg, metadata=metadata)
      +                notified.add(dedup_key)
      +                logger.info(
      +                    "Sent shutdown notification to %s:%s",
      +                    platform_str, chat_id,
      +                )
      +            except Exception as e:
      +                logger.debug(
      +                    "Failed to send shutdown notification to %s:%s: %s",
      +                    platform_str, chat_id, e,
      +                )
      +
           def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None:
               for agent in active_agents.values():
                   try:
      @@ -2018,6 +2077,10 @@ async def _stop_impl() -> None:
                   self._running = False
                   self._draining = True
       
      +            # Notify all chats with active agents BEFORE draining.
      +            # Adapters are still connected here, so messages can be sent.
      +            await self._notify_active_sessions_of_shutdown()
      +
                   timeout = self._restart_drain_timeout
                   active_agents, timed_out = await self._drain_active_agents(timeout)
                   if timed_out:
      @@ -2088,12 +2151,23 @@ async def _stop_impl() -> None:
       
                   # Write a clean-shutdown marker so the next startup knows this
                   # wasn't a crash.  suspend_recently_active() only needs to run
      -            # after unexpected exits — graceful shutdowns already drain
      -            # active agents, so there's no stuck-session risk.
      -            try:
      -                (_hermes_home / ".clean_shutdown").touch()
      -            except Exception:
      -                pass
      +            # after unexpected exits.  However, if the drain timed out and
      +            # agents were force-interrupted, their sessions may be in an
      +            # incomplete state (trailing tool response, no final assistant
      +            # message).  Skip the marker in that case so the next startup
      +            # suspends those sessions — giving users a clean slate instead
      +            # of resuming a half-finished tool loop.
      +            if not timed_out:
      +                try:
      +                    (_hermes_home / ".clean_shutdown").touch()
      +                except Exception:
      +                    pass
      +            else:
      +                logger.info(
      +                    "Skipping .clean_shutdown marker — drain timed out with "
      +                    "interrupted agents; next startup will suspend recently "
      +                    "active sessions."
      +                )
       
                   if self._restart_requested and self._restart_via_service:
                       self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE
    • hermes_cli/main.py
      @@ -4036,7 +4036,40 @@ def cmd_update(args):
                                           capture_output=True, text=True, timeout=15,
                                       )
                                       if restart.returncode == 0:
      -                                    restarted_services.append(svc_name)
      +                                    # Verify the service actually survived the
      +                                    # restart.  systemctl restart returns 0 even
      +                                    # if the new process crashes immediately.
      +                                    import time as _time
      +                                    _time.sleep(3)
      +                                    verify = subprocess.run(
      +                                        scope_cmd + ["is-active", svc_name],
      +                                        capture_output=True, text=True, timeout=5,
      +                                    )
      +                                    if verify.stdout.strip() == "active":
      +                                        restarted_services.append(svc_name)
      +                                    else:
      +                                        # Retry once — transient startup failures
      +                                        # (stale module cache, import race) often
      +                                        # resolve on the second attempt.
      +                                        print(f"  ⚠ {svc_name} died after restart, retrying...")
      +                                        retry = subprocess.run(
      +                                            scope_cmd + ["restart", svc_name],
      +                                            capture_output=True, text=True, timeout=15,
      +                                        )
      +                                        _time.sleep(3)
      +                                        verify2 = subprocess.run(
      +                                            scope_cmd + ["is-active", svc_name],
      +                                            capture_output=True, text=True, timeout=5,
      +                                        )
      +                                        if verify2.stdout.strip() == "active":
      +                                            restarted_services.append(svc_name)
      +                                            print(f"  ✓ {svc_name} recovered on retry")
      +                                        else:
      +                                            print(
      +                                                f"  ✗ {svc_name} failed to stay running after restart.\n"
      +                                                f"    Check logs: journalctl --user -u {svc_name} --since '2 min ago'\n"
      +                                                f"    Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}"
      +                                            )
                                       else:
                                           print(f"  ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}")
                           except (FileNotFoundError, subprocess.TimeoutExpired):
    • tests/gateway/restart_test_helpers.py
      @@ -93,6 +93,12 @@ def make_restart_runner(
           runner._running_agent_count = GatewayRunner._running_agent_count.__get__(
               runner, GatewayRunner
           )
      +    runner._snapshot_running_agents = GatewayRunner._snapshot_running_agents.__get__(
      +        runner, GatewayRunner
      +    )
      +    runner._notify_active_sessions_of_shutdown = (
      +        GatewayRunner._notify_active_sessions_of_shutdown.__get__(runner, GatewayRunner)
      +    )
           runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__(
               runner, GatewayRunner
           )
    • tests/gateway/test_restart_drain.py
      @@ -161,3 +161,84 @@ def fake_popen(cmd, **kwargs):
           assert kwargs["start_new_session"] is True
           assert kwargs["stdout"] is subprocess.DEVNULL
           assert kwargs["stderr"] is subprocess.DEVNULL
      +
      +
      +# ── Shutdown notification tests ──────────────────────────────────────
      +
      +
      [redacted:email]
      +async def test_shutdown_notification_sent_to_active_sessions():
      +    """Active sessions receive a notification when the gateway starts shutting down."""
      +    runner, adapter = make_restart_runner()
      +    source = make_restart_source(chat_id="999", chat_type="dm")
      +    session_key = f"agent:main:telegram:dm:999"
      +    runner._running_agents[session_key] = MagicMock()
      +
      +    await runner._notify_active_sessions_of_shutdown()
      +
      +    assert len(adapter.sent) == 1
      +    assert "shutting down" in adapter.sent[0]
      +    assert "interrupted" in adapter.sent[0]
      +
      +
      [redacted:email]
      +async def test_shutdown_notification_says_restarting_when_restart_requested():
      +    """When _restart_requested is True, the message says 'restarting' and mentions /retry."""
      +    runner, adapter = make_restart_runner()
      +    runner._restart_requested = True
      +    session_key = "agent:main:telegram:dm:999"
      +    runner._running_agents[session_key] = MagicMock()
      +
      +    await runner._notify_active_sessions_of_shutdown()
      +
      +    assert len(adapter.sent) == 1
      +    assert "restarting" in adapter.sent[0]
      +    assert "/retry" in adapter.sent[0]
      +
      +
      [redacted:email]
      +async def test_shutdown_notification_deduplicates_per_chat():
      +    """Multiple sessions in the same chat only get one notification."""
      +    runner, adapter = make_restart_runner()
      +    # Two sessions (different users) in the same chat
      +    runner._running_agents["agent:main:telegram:group:chat1:u1"] = MagicMock()
      +    runner._running_agents["agent:main:telegram:group:chat1:u2"] = MagicMock()
      +
      +    await runner._notify_active_sessions_of_shutdown()
      +
      +    assert len(adapter.sent) == 1
      +
      +
      [redacted:email]
      +async def test_shutdown_notification_skipped_when_no_active_agents():
      +    """No notification is sent when there are no active agents."""
      +    runner, adapter = make_restart_runner()
      +
      +    await runner._notify_active_sessions_of_shutdown()
      +
      +    assert len(adapter.sent) == 0
      +
      +
      [redacted:email]
      +async def test_shutdown_notification_ignores_pending_sentinels():
      +    """Pending sentinels (not-yet-started agents) don't trigger notifications."""
      +    from gateway.run import _AGENT_PENDING_SENTINEL
      +
      +    runner, adapter = make_restart_runner()
      +    runner._running_agents["agent:main:telegram:dm:999"] = _AGENT_PENDING_SENTINEL
      +
      +    await runner._notify_active_sessions_of_shutdown()
      +
      +    assert len(adapter.sent) == 0
      +
      +
      [redacted:email]
      +async def test_shutdown_notification_send_failure_does_not_block():
      +    """If sending a notification fails, the method still completes."""
      +    runner, adapter = make_restart_runner()
      +    adapter.send = AsyncMock(side_effect=Exception("network error"))
      +    session_key = "agent:main:telegram:dm:999"
      +    runner._running_agents[session_key] = MagicMock()
      +
      +    # Should not raise
      +    await runner._notify_active_sessions_of_shutdown()

1 PR closed without merging

From the close comments: done elsewhere 1

  • #8150 fix(gateway): detect systemd in /restart to avoid killing service permanently · done elsewhere → 276d20e
Closed issues · 1
  • Closed#8104[Bug]: /restart slash command hardcodes detached=True, via_service=False — kills systemd-managed gateway permanently
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.67: 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

Merged PRs
#9850
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
14 Apr 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.