fix: reject stale runtime before chat mutation

This commit is contained in:
pxxD1998 2026-07-15 22:20:44 +08:00
parent 5df6b1fe8a
commit 5f1791e08d
3 changed files with 79 additions and 2 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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,
},
}