mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-18 13:40:22 +00:00
fix(runtime): ignore in-memory agent module swaps
This commit is contained in:
+32
-19
@@ -23,18 +23,26 @@ _RESTART_MESSAGE = (
|
||||
)
|
||||
|
||||
|
||||
def _read_agent_revision(agent_dir: Path | None) -> str | None:
|
||||
def _read_agent_revision(
|
||||
agent_dir: Path | None,
|
||||
*,
|
||||
module_path: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Return the loaded Agent checkout HEAD, or ``None`` if it is not tracked."""
|
||||
if agent_dir is None:
|
||||
return None
|
||||
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
if module_path is None:
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
try:
|
||||
module_path = Path(module_file).resolve()
|
||||
worktree_result = subprocess.run(
|
||||
["git", "-C", str(agent_dir), "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
@@ -79,6 +87,7 @@ def _read_agent_revision(agent_dir: Path | None) -> str | None:
|
||||
|
||||
|
||||
_AGENT_SOURCE_DIR: Path | None = None
|
||||
_AGENT_MODULE_PATH: Path | None = None
|
||||
_AGENT_REVISION: str | None = None
|
||||
_AIAgent = None
|
||||
_RUNTIME_LOCK = threading.Lock()
|
||||
@@ -88,33 +97,34 @@ class AgentRuntimeChangedError(RuntimeError):
|
||||
"""Raised when the loaded Agent runtime no longer matches its source tree."""
|
||||
|
||||
|
||||
def _loaded_agent_source_dir() -> Path | None:
|
||||
"""Return the source directory that actually supplied ``run_agent``."""
|
||||
def _loaded_agent_source_identity() -> tuple[Path, Path] | None:
|
||||
"""Return the source directory and file that supplied ``run_agent``."""
|
||||
module = sys.modules.get("run_agent")
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return None
|
||||
try:
|
||||
return Path(module_file).resolve().parent
|
||||
module_path = Path(module_file).resolve()
|
||||
return module_path.parent, module_path
|
||||
except (OSError, RuntimeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _capture_loaded_agent_revision() -> None:
|
||||
"""Bind the guard to the checkout that supplied the loaded Agent module."""
|
||||
global _AGENT_SOURCE_DIR, _AGENT_REVISION
|
||||
global _AGENT_SOURCE_DIR, _AGENT_MODULE_PATH, _AGENT_REVISION
|
||||
|
||||
source_dir = _loaded_agent_source_dir()
|
||||
if source_dir is None:
|
||||
return
|
||||
current_revision = _read_agent_revision(source_dir)
|
||||
if _AGENT_SOURCE_DIR is not None and source_dir != _AGENT_SOURCE_DIR:
|
||||
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
|
||||
if _AGENT_REVISION is not None:
|
||||
if current_revision != _AGENT_REVISION:
|
||||
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
|
||||
ensure_agent_runtime_current()
|
||||
return
|
||||
|
||||
identity = _loaded_agent_source_identity()
|
||||
if identity is None:
|
||||
return
|
||||
source_dir, module_path = identity
|
||||
current_revision = _read_agent_revision(source_dir, module_path=module_path)
|
||||
_AGENT_SOURCE_DIR = source_dir
|
||||
_AGENT_MODULE_PATH = module_path
|
||||
_AGENT_REVISION = current_revision
|
||||
|
||||
|
||||
@@ -122,7 +132,10 @@ def ensure_agent_runtime_current() -> None:
|
||||
"""Reject a known Git checkout change instead of mixing Python modules."""
|
||||
if _AGENT_REVISION is None:
|
||||
return
|
||||
if _read_agent_revision(_AGENT_SOURCE_DIR) != _AGENT_REVISION:
|
||||
if (
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
|
||||
|
||||
|
||||
|
||||
@@ -109,7 +109,11 @@ def test_initial_non_git_source_preserves_supported_runtime(monkeypatch):
|
||||
from api import agent_runtime
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
monkeypatch.setattr(agent_runtime, "_read_agent_revision", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
agent_runtime,
|
||||
"_read_agent_revision",
|
||||
lambda _path, **_kwargs: None,
|
||||
)
|
||||
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
|
||||
@@ -134,11 +138,13 @@ def test_untracked_loaded_module_inside_outer_git_repo_is_non_git(
|
||||
loaded_module.__file__ = str(module_file)
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == module_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == module_file.resolve()
|
||||
assert agent_runtime._AGENT_REVISION is None
|
||||
|
||||
(outer_repo / "tracked.txt").write_text("unrelated change\n", encoding="utf-8")
|
||||
@@ -168,11 +174,13 @@ def test_untracked_module_pathspec_metacharacters_are_literal(monkeypatch, tmp_p
|
||||
loaded_module.__file__ = str(module_file)
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == untracked_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == module_file.resolve()
|
||||
assert agent_runtime._AGENT_REVISION is None
|
||||
|
||||
|
||||
@@ -197,11 +205,13 @@ def test_revision_identity_comes_from_loaded_agent_module(monkeypatch, tmp_path:
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_DIR", configured_dir)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == loaded_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == (loaded_dir / "run_agent.py").resolve()
|
||||
assert agent_runtime._AGENT_REVISION == _git(loaded_dir, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
@@ -210,7 +220,11 @@ def test_known_revision_becoming_unreadable_fails_closed(monkeypatch):
|
||||
from api import agent_runtime
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "known-revision")
|
||||
monkeypatch.setattr(agent_runtime, "_read_agent_revision", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
agent_runtime,
|
||||
"_read_agent_revision",
|
||||
lambda _path, **_kwargs: None,
|
||||
)
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
@@ -227,9 +241,14 @@ def test_import_recapture_cannot_downgrade_known_revision(monkeypatch, tmp_path:
|
||||
loaded_module.__dict__["AIAgent"] = type("AIAgent", (), {})
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", source_dir / "run_agent.py")
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "known-revision")
|
||||
revisions = iter(("known-revision", None))
|
||||
monkeypatch.setattr(agent_runtime, "_read_agent_revision", lambda _path: next(revisions))
|
||||
monkeypatch.setattr(
|
||||
agent_runtime,
|
||||
"_read_agent_revision",
|
||||
lambda _path, **_kwargs: next(revisions),
|
||||
)
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.require_ai_agent_class()
|
||||
@@ -237,6 +256,40 @@ def test_import_recapture_cannot_downgrade_known_revision(monkeypatch, tmp_path:
|
||||
assert agent_runtime._AGENT_REVISION == "known-revision"
|
||||
|
||||
|
||||
def test_in_memory_agent_swap_does_not_mimic_source_revision_change(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
"""Only the bound checkout revision, not ``sys.modules`` swaps, defines staleness."""
|
||||
from api import agent_runtime
|
||||
|
||||
agent_dir = tmp_path / "loaded-agent"
|
||||
agent_dir.mkdir()
|
||||
module_file = agent_dir / "run_agent.py"
|
||||
module_file.write_text("class AIAgent: pass\n", encoding="utf-8")
|
||||
_git(agent_dir, "init", "-q")
|
||||
_git(agent_dir, "add", "run_agent.py")
|
||||
_git(agent_dir, "commit", "-qm", "loaded agent")
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(module_file)
|
||||
loaded_module.__dict__["AIAgent"] = type("LoadedAgent", (), {})
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", None)
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", None)
|
||||
agent_runtime._capture_loaded_agent_revision()
|
||||
|
||||
replacement_class = type("ReplacementAgent", (), {})
|
||||
replacement_module = types.ModuleType("run_agent")
|
||||
replacement_module.__dict__["AIAgent"] = replacement_class
|
||||
monkeypatch.setitem(sys.modules, "run_agent", replacement_module)
|
||||
|
||||
assert agent_runtime.require_ai_agent_class() is replacement_class
|
||||
assert agent_runtime._AGENT_SOURCE_DIR == agent_dir.resolve()
|
||||
assert agent_runtime._AGENT_MODULE_PATH == module_file.resolve()
|
||||
assert agent_runtime._AGENT_REVISION == _git(agent_dir, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_runner_local_bypasses_webui_agent_barrier(monkeypatch):
|
||||
"""Runner-local execution is owned by the runner, not this process."""
|
||||
from api import routes
|
||||
|
||||
Reference in New Issue
Block a user