fix(runtime): ignore in-memory agent module swaps

This commit is contained in:
pxxD1998
2026-07-16 22:27:53 +08:00
parent 0bab6a3d7a
commit 2415995204
2 changed files with 88 additions and 22 deletions
+32 -19
View File
@@ -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)
+56 -3
View File
@@ -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