From 5f1791e08d5cf234a573b797f885805a89714791 Mon Sep 17 00:00:00 2001 From: pxxD1998 <214340659+pxxD1998@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:20:44 +0800 Subject: [PATCH] fix: reject stale runtime before chat mutation --- api/routes.py | 22 ++++++++- docs/troubleshooting.md | 2 +- tests/test_agent_runtime_revision_guard.py | 57 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/api/routes.py b/api/routes.py index 93b63aa7..e0fdeafe 100644 --- a/api/routes.py +++ b/api/routes.py @@ -34,7 +34,11 @@ from contextlib import closing from urllib.parse import parse_qs, quote, unquote, urljoin, urlsplit from urllib.error import HTTPError, URLError from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener -from api.agent_runtime import ensure_agent_runtime_current, require_ai_agent_class +from api.agent_runtime import ( + AgentRuntimeChangedError, + ensure_agent_runtime_current, + require_ai_agent_class, +) from api.agent_sessions import ( MESSAGING_SOURCES, _looks_like_default_cli_title, @@ -21663,6 +21667,22 @@ def _handle_chat_start(handler, body, diag=None): require(body, "session_id") except ValueError as e: return bad(handler, str(e)) + # Reject a stale local Agent runtime before materialising, claiming, or + # mutating any session state. Gateway-backed turns run in the gateway's + # process and do not depend on this WebUI process's imported checkout. + if not webui_gateway_chat_enabled(get_config()): + try: + ensure_agent_runtime_current() + except AgentRuntimeChangedError as exc: + return j( + handler, + { + "error": str(exc), + "type": "agent_runtime_stale", + "retryable": True, + }, + status=409, + ) diag.stage("get_session") if diag else None try: s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index da8a5dcf..5d0e7ea6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -181,7 +181,7 @@ turn in the exhausted session instead of being blocked with recovery guidance. **Symptom.** An action that uses the in-process Agent runtime stops with a message telling you to restart Hermes WebUI. This can happen after `hermes update`, a Git checkout/pull in the Agent source tree, or another tool updates Hermes Agent without restarting the already-running WebUI backend. -**Why.** WebUI currently imports `run_agent.AIAgent` into its long-lived Python process. Python keeps imported modules in memory. Continuing after the Agent Git revision changes could combine cached modules from the old revision with source read from the new revision, producing misleading `ImportError`s or inconsistent runtime state. WebUI therefore fails closed instead of attempting a partial in-process reload. +**Why.** WebUI currently imports `run_agent.AIAgent` into its long-lived Python process. Python keeps imported modules in memory. Continuing after a known Agent Git revision changes could combine cached modules from the old revision with source read from the new revision, producing misleading `ImportError`s or inconsistent runtime state. For local Agent-backed chat, WebUI therefore returns a retryable `409 agent_runtime_stale` before claiming or mutating session state instead of attempting a partial in-process reload. Gateway-backed chat runs in the gateway process and is not blocked by this WebUI-local check. Non-Git Agent installs preserve their existing behavior because there is no revision identity to compare. **Diagnostic.** Compare the running WebUI process start time with the Agent checkout revision and recent update history. If the Agent was updated after WebUI started, restart WebUI before investigating individual missing-symbol errors. diff --git a/tests/test_agent_runtime_revision_guard.py b/tests/test_agent_runtime_revision_guard.py index f9cc1ae6..e9de2bbc 100644 --- a/tests/test_agent_runtime_revision_guard.py +++ b/tests/test_agent_runtime_revision_guard.py @@ -7,6 +7,8 @@ from pathlib import Path import subprocess import sys +import pytest + REPO = Path(__file__).resolve().parents[1] @@ -99,3 +101,58 @@ else: ) assert result.returncode == 0, result.stdout + result.stderr + + +def test_initial_non_git_source_preserves_supported_runtime(monkeypatch): + """Non-Git installs cannot be compared, so they preserve existing behavior.""" + from api import agent_runtime + + monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None) + monkeypatch.setattr(agent_runtime, "_read_agent_revision", lambda _path: None) + + agent_runtime.ensure_agent_runtime_current() + + +def test_known_revision_becoming_unreadable_fails_closed(monkeypatch): + """Losing a previously-known revision is indistinguishable from source drift.""" + from api import agent_runtime + + monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "known-revision") + monkeypatch.setattr(agent_runtime, "_read_agent_revision", lambda _path: None) + + with pytest.raises(agent_runtime.AgentRuntimeChangedError): + agent_runtime.ensure_agent_runtime_current() + + +def test_chat_start_rejects_stale_runtime_before_session_materialization(monkeypatch): + """A stale local runtime must not claim, create, or mutate session state.""" + from api import agent_runtime, routes + + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) + monkeypatch.setattr(routes, "get_config", lambda: {}) + + def stale(): + raise agent_runtime.AgentRuntimeChangedError("restart required") + + monkeypatch.setattr(routes, "ensure_agent_runtime_current", stale) + + def must_not_materialize(*_args, **_kwargs): + raise AssertionError("session materialized before stale-runtime barrier") + + monkeypatch.setattr(routes, "_get_or_materialize_session", must_not_materialize) + monkeypatch.setattr( + routes, + "j", + lambda _handler, payload, status=200: {"status": status, "payload": payload}, + ) + + response = routes._handle_chat_start(object(), {"session_id": "session-1"}) + + assert response == { + "status": 409, + "payload": { + "error": "restart required", + "type": "agent_runtime_stale", + "retryable": True, + }, + }