From 513d867dd6672d20af442ae872c3b4a8d4f4fb38 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:28:09 +0800 Subject: [PATCH] fix(plugin): suppress delayed install reloads (#6484) --- app/runtime/extensions/plugin_manager.py | 22 +++++++++++-- .../fixtures/architecture/ruff-baseline.json | 3 -- tests/test_plugin_monitor_lifecycle.py | 33 +++++++++++++++++-- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index 8211e2834..6e7d685a9 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -3,6 +3,7 @@ import concurrent.futures import inspect import posixpath import threading +import time from contextlib import contextmanager from pathlib import Path from typing import ( @@ -167,6 +168,8 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """插件管理器""" CONFIG_WATCH = {"DEV", "PLUGIN_AUTO_RELOAD", "PLUGIN_LOCAL_REPO_PATHS"} AGENT_TOOLS_BUILD_MAX_ATTEMPTS = 3 + # 略长于 watchfiles 默认 debounce,吸收包写入完成后才交付的延迟批次。 + MONITOR_SETTLE_SECONDS = 2.0 def __init__(self): """初始化插件注册表、缓存和开发模式监控状态。""" @@ -213,6 +216,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._recent_local_sync: Dict[str, float] = {} self._monitor_suppression_lock = threading.Lock() self._suppressed_monitor_plugins: Dict[str, int] = {} + self._monitor_suppressed_until: Dict[str, float] = {} self._plugin_paths = PluginPathResolver( runtime_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", running=lambda: self._running_plugins, @@ -724,7 +728,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): @contextmanager def suppress_plugin_monitor(self, plugin_id: str): - """在插件目录原子更新期间阻止文件监控抢先重载半成品。""" + """在插件包写入及其文件事件收敛期间阻止监控重复重载。""" with self.mutation("更新插件包"): normalized_id = plugin_id.lower() with self._monitor_suppression_lock: @@ -738,13 +742,25 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): count = self._suppressed_monitor_plugins.get(normalized_id, 0) if count <= 1: self._suppressed_monitor_plugins.pop(normalized_id, None) + self._monitor_suppressed_until[normalized_id] = ( + time.monotonic() + self.MONITOR_SETTLE_SECONDS + ) else: self._suppressed_monitor_plugins[normalized_id] = count - 1 def is_plugin_monitor_suppressed(self, plugin_id: str) -> bool: - """判断指定插件是否处于安装或替换写入阶段。""" + """判断插件是否处于包写入或延迟文件事件收敛阶段。""" with self._monitor_suppression_lock: - return self._suppressed_monitor_plugins.get(plugin_id.lower(), 0) > 0 + normalized_id = plugin_id.lower() + if self._suppressed_monitor_plugins.get(normalized_id, 0) > 0: + return True + suppressed_until = self._monitor_suppressed_until.get(normalized_id) + if suppressed_until is None: + return False + if time.monotonic() < suppressed_until: + return True + self._monitor_suppressed_until.pop(normalized_id, None) + return False def remove_plugin(self, plugin_id: str): """ diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index 8a6fa8018..6758b3e1d 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -1007,9 +1007,6 @@ "app/schemas/message.py": { "I001": 1 }, - "app/schemas/plugin.py": { - "I001": 1 - }, "app/schemas/response.py": { "I001": 1 }, diff --git a/tests/test_plugin_monitor_lifecycle.py b/tests/test_plugin_monitor_lifecycle.py index 7cb26b4fe..a9dd32001 100644 --- a/tests/test_plugin_monitor_lifecycle.py +++ b/tests/test_plugin_monitor_lifecycle.py @@ -907,8 +907,11 @@ def test_monitor_reload_refreshes_source_and_instance_routes(monkeypatch) -> Non ] -def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None: - """同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。""" +def test_plugin_monitor_suppression_covers_delayed_write_events( + monkeypatch, + tmp_path, +) -> None: + """重叠写入退出后的延迟批次不重复重载,后续真实修改仍可重载。""" _reset_plugin_manager() reset_plugin_system() _patch_runtime_settings( @@ -917,7 +920,26 @@ def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None: PLUGIN_AUTO_RELOAD=False, ROOT_PATH=MagicMock(), ) + now = [100.0] + monkeypatch.setattr(plugin_manager_module.time, "monotonic", lambda: now[0]) manager = PluginManager() + reload_plugin = MagicMock() + monitor = PluginChangeMonitor( + runtime_root=tmp_path, + local_roots=lambda: [], + stop_event=threading.Event(), + recent_sync={}, + federated_change=lambda _path: None, + runtime_plugin=lambda _path: "DemoPlugin", + local_candidate=lambda _path: None, + sync_local=MagicMock(), + reload_plugin=reload_plugin, + dependency_manifest_status=lambda _path: None, + watch=lambda *_args, **_kwargs: (), + log=MagicMock(), + monitor_suppressed=manager.is_plugin_monitor_suppressed, + ) + changes = {("modified", str(tmp_path / "demo" / "plugin.py"))} with manager.suppress_plugin_monitor("DemoPlugin"): assert manager.is_plugin_monitor_suppressed("demoplugin") is True @@ -925,7 +947,12 @@ def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None: assert manager.is_plugin_monitor_suppressed("DemoPlugin") is True assert manager.is_plugin_monitor_suppressed("DemoPlugin") is True - assert manager.is_plugin_monitor_suppressed("DemoPlugin") is False + monitor._process_changes(changes) + reload_plugin.assert_not_called() + + now[0] += manager.MONITOR_SETTLE_SECONDS + monitor._process_changes(changes) + reload_plugin.assert_called_once_with("DemoPlugin") _reset_plugin_manager()