From 2415995204530f3940bb78c8bdf86c67cd091089 Mon Sep 17 00:00:00 2001 From: pxxD1998 <214340659+pxxD1998@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:27:53 +0800 Subject: [PATCH] fix(runtime): ignore in-memory agent module swaps --- api/agent_runtime.py | 51 ++++++++++++------- tests/test_agent_runtime_revision_guard.py | 59 ++++++++++++++++++++-- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/api/agent_runtime.py b/api/agent_runtime.py index 3c9306f45..865a2fd50 100644 --- a/api/agent_runtime.py +++ b/api/agent_runtime.py @@ -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) diff --git a/tests/test_agent_runtime_revision_guard.py b/tests/test_agent_runtime_revision_guard.py index 625f3e59d..9712f711f 100644 --- a/tests/test_agent_runtime_revision_guard.py +++ b/tests/test_agent_runtime_revision_guard.py @@ -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