fix: 完善插件恢复与运行态收敛 (#6376)

This commit is contained in:
InfinityPacer
2026-08-21 18:09:29 +08:00
committed by GitHub
parent 2d80f9f04f
commit b9bbc8913c
32 changed files with 1823 additions and 163 deletions
+13 -5
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6067,
"edge_sha256": "a10a5353df10ba2817b49f6994eefd99266c853eda5119f36b2a68bafb9221ed",
"edge_count": 6075,
"edge_sha256": "3cea396b7570abbb70a8a30a9cd18006b52e21aa9d8d8106d2596baa52573d83",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -540,6 +540,7 @@
"app.agent.tools.impl._plugin_tool_utils -> app.runtime",
"app.agent.tools.impl._plugin_tool_utils -> app.runtime.config",
"app.agent.tools.impl._plugin_tool_utils -> app.schemas",
"app.agent.tools.impl._plugin_tool_utils -> app.schemas.plugin",
"app.agent.tools.impl._plugin_tool_utils -> app.schemas.types",
"app.agent.tools.impl._system_setting_utils -> app.agent",
"app.agent.tools.impl._system_setting_utils -> app.agent.policy",
@@ -1136,6 +1137,8 @@
"app.agent.tools.impl.reload_plugin -> app.agent.tools.tags",
"app.agent.tools.impl.reload_plugin -> app.runtime",
"app.agent.tools.impl.reload_plugin -> app.runtime.log",
"app.agent.tools.impl.reload_plugin -> app.schemas",
"app.agent.tools.impl.reload_plugin -> app.schemas.plugin",
"app.agent.tools.impl.run_agent_task -> app.agent",
"app.agent.tools.impl.run_agent_task -> app.agent.tools",
"app.agent.tools.impl.run_agent_task -> app.agent.tools.base",
@@ -3118,9 +3121,6 @@
"app.chain.system -> app.adapters.network.http",
"app.chain.system -> app.adapters.system",
"app.chain.system -> app.adapters.system.host",
"app.chain.system -> app.application",
"app.chain.system -> app.application.plugin",
"app.chain.system -> app.application.plugin.runtime",
"app.chain.system -> app.chain",
"app.chain.system -> app.runtime",
"app.chain.system -> app.runtime.config",
@@ -5327,6 +5327,8 @@
"app.runtime.extensions.plugin.dependency -> app.runtime.extensions",
"app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin",
"app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system",
"app.runtime.extensions.plugin.lifecycle -> app.schemas",
"app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin",
"app.runtime.extensions.plugin.metadata -> app.runtime",
"app.runtime.extensions.plugin.metadata -> app.runtime.extensions",
"app.runtime.extensions.plugin.metadata -> app.runtime.extensions.plugin",
@@ -5344,6 +5346,8 @@
"app.runtime.extensions.plugin.projection -> app.runtime.log",
"app.runtime.extensions.plugin.projection -> app.schemas",
"app.runtime.extensions.plugin.projection -> app.schemas.plugin",
"app.runtime.extensions.plugin.registry -> app.schemas",
"app.runtime.extensions.plugin.registry -> app.schemas.plugin",
"app.runtime.extensions.plugin.sync -> app.runtime",
"app.runtime.extensions.plugin.sync -> app.runtime.extensions",
"app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin",
@@ -5735,6 +5739,9 @@
"app.startup.lifecycle -> app.adapters.external.server",
"app.startup.lifecycle -> app.adapters.network",
"app.startup.lifecycle -> app.adapters.network.http",
"app.startup.lifecycle -> app.application",
"app.startup.lifecycle -> app.application.plugin",
"app.startup.lifecycle -> app.application.plugin.runtime",
"app.startup.lifecycle -> app.chain",
"app.startup.lifecycle -> app.chain.system",
"app.startup.lifecycle -> app.db",
@@ -5889,6 +5896,7 @@
"app.startup.plugins_initializer -> app.runtime.config",
"app.startup.plugins_initializer -> app.runtime.extensions",
"app.startup.plugins_initializer -> app.runtime.extensions.plugin",
"app.startup.plugins_initializer -> app.runtime.extensions.plugin.dependency",
"app.startup.plugins_initializer -> app.runtime.extensions.plugin.storage",
"app.startup.plugins_initializer -> app.runtime.extensions.plugin.system",
"app.startup.plugins_initializer -> app.runtime.extensions.plugin_manager",
+23
View File
@@ -11,6 +11,7 @@ from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
from app.agent.tools.impl.query_plugin_data import QueryPluginDataTool
from app.agent.tools.impl.reload_plugin import ReloadPluginTool
from app.schemas.plugin import PluginRuntimeStatus
from app.agent.tools.impl.uninstall_plugin import UninstallPluginTool
from app.agent.tools.impl.update_plugin_config import UpdatePluginConfigTool
@@ -215,6 +216,7 @@ def test_reload_plugin_triggers_runtime_refresh() -> None:
"app.agent.tools.impl.reload_plugin.reload_plugin_runtime"
) as reload_plugin_runtime,
):
reload_plugin_runtime.return_value = PluginRuntimeStatus.ACTIVE
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
@@ -223,6 +225,27 @@ def test_reload_plugin_triggers_runtime_refresh() -> None:
reload_plugin_runtime.assert_called_once_with("DemoPlugin")
def test_reload_plugin_reports_runtime_failure() -> None:
"""重载未进入 active 时不得继续向智能体报告成功。"""
tool = ReloadPluginTool(session_id="session-1", user_id="10001")
with (
patch(
"app.agent.tools.impl.reload_plugin.get_plugin_snapshot",
side_effect=[_plugin_snapshot(), _plugin_snapshot(state=False)],
),
patch(
"app.agent.tools.impl.reload_plugin.reload_plugin_runtime",
return_value=PluginRuntimeStatus.LOAD_FAILED,
),
):
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
payload = json.loads(result)
assert payload["success"] is False
assert payload["runtime_status"] == "load_failed"
def test_install_plugin_installs_market_candidate() -> None:
"""
安装插件工具会使用市场候选携带的仓库地址。
+25
View File
@@ -139,6 +139,31 @@ def test_lifespan_normal_mode_starts_full_runtime(monkeypatch):
_assert_completed_once(step)
def test_lifespan_waits_for_plugin_settlement_before_shutdown(monkeypatch):
"""关停必须等待插件恢复线程结束,避免与备份和资源释放并发。"""
shutdown_steps = _patch_lifespan(monkeypatch)
order = []
shutdown_steps["backup_plugins"].side_effect = lambda: order.append("backup")
async def run_lifespan():
started = asyncio.Event()
release = asyncio.Event()
async def settle_plugins():
started.set()
await release.wait()
order.append("settled")
lifecycle.init_extra.side_effect = settle_plugins
async with lifecycle.lifespan(FastAPI()):
await started.wait()
asyncio.get_running_loop().call_later(0.02, release.set)
asyncio.run(run_lifespan())
assert order[:2] == ["settled", "backup"]
def test_lifespan_configures_plugin_services_before_restore(monkeypatch):
"""插件恢复依赖的外部系统服务必须先于恢复阶段完成装配。"""
shutdown_steps = _patch_lifespan(monkeypatch)
+334
View File
@@ -0,0 +1,334 @@
"""插件持久化备份与 Docker 重置恢复合同测试。"""
import errno
from pathlib import Path
from types import SimpleNamespace
from app.adapters.external import market as market_module
from app.chain import system as system_module
from app.chain.system import SystemChain
def _patch_docker_paths(monkeypatch, tmp_path: Path, *, reset: bool) -> Path:
"""把插件恢复路径和 Docker 重置条件隔离到临时目录。"""
config_dir = tmp_path / "config"
runtime_dir = tmp_path / "app" / "plugins"
config_dir.mkdir(parents=True)
runtime_dir.mkdir(parents=True)
monkeypatch.setattr(
system_module,
"settings",
SimpleNamespace(ROOT_PATH=tmp_path, CONFIG_PATH=config_dir),
)
monkeypatch.setattr(
system_module.SystemUtils,
"is_docker",
staticmethod(lambda: True),
)
monkeypatch.setattr(
system_module.SystemHelper,
"is_system_reset",
lambda _self: reset,
)
return runtime_dir
def _patch_market_paths(monkeypatch, tmp_path: Path) -> tuple[Path, Path]:
"""把插件更新后的持久化备份路径隔离到临时目录。"""
plugin_root = tmp_path / "app" / "plugins"
config_dir = tmp_path / "config"
plugin_root.mkdir(parents=True)
config_dir.mkdir(parents=True)
monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root)
monkeypatch.setattr(
market_module,
"settings",
SimpleNamespace(CONFIG_PATH=config_dir),
)
monkeypatch.setattr(
market_module.SystemUtils,
"is_docker",
staticmethod(lambda: True),
)
return plugin_root, config_dir / "plugins_backup"
def _write_plugin(root: Path, plugin_id: str, filename: str, content: str) -> Path:
plugin_dir = root / plugin_id
plugin_dir.mkdir(parents=True, exist_ok=True)
target = plugin_dir / filename
target.write_text(content, encoding="utf-8")
return target
def test_backup_plugins_refreshes_existing_snapshot(monkeypatch, tmp_path):
"""关停备份应刷新同名插件并移除旧快照中的遗留文件。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_root = tmp_path / "config" / "plugins_backup"
backup_dir = backup_root / "demo"
_write_plugin(runtime_dir, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
_write_plugin(backup_root, "demo", "stale.py", "stale")
SystemChain.backup_plugins()
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "new"
assert not (backup_dir / "stale.py").exists()
def test_backup_plugins_failure_preserves_previous_snapshot(monkeypatch, tmp_path):
"""复制新快照失败时应保留上一份可恢复内容。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_root = tmp_path / "config" / "plugins_backup"
backup_dir = backup_root / "demo"
_write_plugin(runtime_dir, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
def fail_copy(*_args, **_kwargs):
raise OSError("copy failed")
monkeypatch.setattr(system_module.shutil, "copytree", fail_copy)
SystemChain.backup_plugins()
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "old"
def test_backup_plugins_keeps_snapshot_missing_from_runtime(monkeypatch, tmp_path):
"""运行目录缺失时不得删除唯一的持久化备份。"""
_patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_root = tmp_path / "config" / "plugins_backup"
backup_file = _write_plugin(backup_root, "demo", "plugin.py", "recoverable")
SystemChain.backup_plugins()
assert backup_file.read_text(encoding="utf-8") == "recoverable"
def test_restore_plugins_keeps_backup_on_regular_start(monkeypatch, tmp_path):
"""普通重启保留备份,等待真正的容器重置场景消费。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "demo", "plugin.py", "stable")
SystemChain.restore_plugins()
assert not (runtime_dir / "demo").exists()
assert (backup_dir / "demo" / "plugin.py").exists()
def test_restore_plugins_consumes_backup_after_source_restore(monkeypatch, tmp_path):
"""源码恢复完成即可消费备份,依赖恢复由启动后台任务统一处理。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "stable")
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "stable"
assert not backup_dir.exists()
def test_restore_plugins_retries_only_missing_sources(monkeypatch, tmp_path):
"""恢复失败后只补仍缺失的目录,不覆盖用户随后重新安装的插件。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup")
reset_state = {"value": True}
monkeypatch.setattr(
system_module.SystemHelper,
"is_system_reset",
lambda _self: reset_state["value"],
)
original_copytree = system_module.shutil.copytree
def fail_copy(source, target, *args, **kwargs):
if Path(source).name == "DemoPlugin":
raise OSError("copy failed")
return original_copytree(source, target, *args, **kwargs)
monkeypatch.setattr(system_module.shutil, "copytree", fail_copy)
SystemChain.restore_plugins()
pending = backup_dir / SystemChain._plugin_restore_pending_file
assert pending.exists()
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "reinstalled")
reset_state["value"] = False
monkeypatch.setattr(system_module.shutil, "copytree", original_copytree)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "reinstalled"
assert not backup_dir.exists()
def test_restore_plugins_retries_existing_target_after_copy_failure(
monkeypatch,
tmp_path,
):
"""原目标已存在时,失败重试仍应完成备份版本的原子替换。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "runtime-old")
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup-new")
reset_state = {"value": True}
monkeypatch.setattr(
system_module.SystemHelper,
"is_system_reset",
lambda _self: reset_state["value"],
)
original_copytree = system_module.shutil.copytree
def fail_copy(source, target, *args, **kwargs):
if Path(source).name == "DemoPlugin":
raise OSError("copy failed")
return original_copytree(source, target, *args, **kwargs)
monkeypatch.setattr(system_module.shutil, "copytree", fail_copy)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "runtime-old"
reset_state["value"] = False
monkeypatch.setattr(system_module.shutil, "copytree", original_copytree)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "backup-new"
assert not backup_dir.exists()
def test_restore_plugins_falls_back_when_overlay_rename_returns_exdev(
monkeypatch,
tmp_path,
):
"""镜像层目录拒绝 rename 时仍能完成可恢复的快照替换。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "runtime-old")
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup-new")
original_replace = Path.replace
def exdev_for_existing_target(self, target):
if self == runtime_dir / "DemoPlugin":
raise OSError(errno.EXDEV, "cross-device link")
return original_replace(self, target)
monkeypatch.setattr(Path, "replace", exdev_for_existing_target)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "backup-new"
assert not backup_dir.exists()
def test_restore_plugins_restores_previous_after_partial_overlay_removal(
monkeypatch,
tmp_path,
):
"""overlayfs 删除旧目录部分失败时仍恢复完整旧快照。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=True)
_write_plugin(runtime_dir, "DemoPlugin", "plugin.py", "runtime-old")
_write_plugin(runtime_dir, "DemoPlugin", "settings.json", "settings-old")
backup_dir = tmp_path / "config" / "plugins_backup"
_write_plugin(backup_dir, "DemoPlugin", "plugin.py", "backup-new")
original_replace = Path.replace
original_rmtree = system_module.shutil.rmtree
removal_attempts = 0
def exdev_for_existing_target(self, target):
if self == runtime_dir / "DemoPlugin":
raise OSError(errno.EXDEV, "cross-device link")
return original_replace(self, target)
def fail_after_partial_removal(path, *args, **kwargs):
nonlocal removal_attempts
if Path(path) == runtime_dir / "DemoPlugin" and removal_attempts == 0:
removal_attempts += 1
(runtime_dir / "DemoPlugin" / "plugin.py").unlink()
raise OSError("directory removal interrupted")
return original_rmtree(path, *args, **kwargs)
monkeypatch.setattr(Path, "replace", exdev_for_existing_target)
monkeypatch.setattr(system_module.shutil, "rmtree", fail_after_partial_removal)
SystemChain.restore_plugins()
assert (runtime_dir / "DemoPlugin" / "plugin.py").read_text(
encoding="utf-8"
) == "runtime-old"
assert (runtime_dir / "DemoPlugin" / "settings.json").read_text(
encoding="utf-8"
) == "settings-old"
assert backup_dir.exists()
assert (backup_dir / SystemChain._plugin_restore_pending_file).exists()
def test_backup_keeps_restore_retry_marker(monkeypatch, tmp_path):
"""关停备份不得清除尚未完成的恢复标记。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_dir = tmp_path / "config" / "plugins_backup"
pending = backup_dir / SystemChain._plugin_restore_pending_file
pending.parent.mkdir(parents=True)
pending.touch()
_write_plugin(runtime_dir, "demo", "plugin.py", "current")
SystemChain.backup_plugins()
assert pending.exists()
def test_backup_does_not_overwrite_failed_restore_snapshot(monkeypatch, tmp_path):
"""待重试项目的原快照必须跨关停保留,避免恢复材料被当前目录覆盖。"""
runtime_dir = _patch_docker_paths(monkeypatch, tmp_path, reset=False)
backup_dir = tmp_path / "config" / "plugins_backup"
pending = backup_dir / SystemChain._plugin_restore_pending_file
pending.parent.mkdir(parents=True)
pending.write_text(
'{"failed_items": {"demo": false}}', encoding="utf-8"
)
_write_plugin(runtime_dir, "demo", "plugin.py", "reinstalled")
_write_plugin(backup_dir, "demo", "plugin.py", "recoverable")
SystemChain.backup_plugins()
assert (backup_dir / "demo" / "plugin.py").read_text(encoding="utf-8") == "recoverable"
def test_market_refresh_replaces_snapshot_and_removes_stale_files(monkeypatch, tmp_path):
"""插件更新成功后应刷新对应持久化快照。"""
plugin_root, backup_root = _patch_market_paths(monkeypatch, tmp_path)
backup_dir = backup_root / "demo"
_write_plugin(plugin_root, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
_write_plugin(backup_root, "demo", "stale.py", "stale")
assert market_module.PluginHelper.refresh_persistent_plugin_backup("demo") is True
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "new"
assert not (backup_dir / "stale.py").exists()
def test_market_refresh_failure_preserves_previous_snapshot(monkeypatch, tmp_path):
"""插件更新备份失败时应继续保留旧快照。"""
plugin_root, backup_root = _patch_market_paths(monkeypatch, tmp_path)
backup_dir = backup_root / "demo"
_write_plugin(plugin_root, "demo", "plugin.py", "new")
_write_plugin(backup_root, "demo", "plugin.py", "old")
def fail_copy(*_args, **_kwargs):
raise OSError("copy failed")
monkeypatch.setattr(market_module.shutil, "copytree", fail_copy)
assert market_module.PluginHelper.refresh_persistent_plugin_backup("demo") is False
assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "old"
+57
View File
@@ -0,0 +1,57 @@
"""插件安装事实与运行状态投影测试。"""
from types import SimpleNamespace
from app.runtime.extensions.plugin.catalog import PluginCatalogFacade
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
def test_installed_catalog_keeps_plugins_that_are_not_loaded():
"""已安装清单中的插件即使缺依赖或源码也必须保留可观察卡片。"""
class ActivePlugin:
plugin_name = "已运行插件"
plugin_version = "1.0.0"
plugin_order = 0
active_instance = SimpleNamespace(get_state=lambda: True)
statuses = {
"ActivePlugin": PluginRuntimeStatus.ACTIVE,
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
"SourceMissing": PluginRuntimeStatus.SOURCE_MISSING,
}
facade = PluginCatalogFacade(
classes=lambda: {"ActivePlugin": ActivePlugin},
running=lambda: {"ActivePlugin": active_instance},
storage=lambda: SimpleNamespace(
read=lambda key: [
"ActivePlugin",
"DependencyPending",
"SourceMissing",
] if key is SystemConfigKey.UserInstalledPlugins else None,
),
system=lambda: SimpleNamespace(),
market_catalog=lambda: None,
market_loader=lambda *_args, **_kwargs: [],
async_market_loader=lambda *_args, **_kwargs: [],
map_plugin=lambda **_kwargs: None,
auth_checker=lambda **_kwargs: True,
plugin_attr=lambda _plugin_id, _attr: None,
runtime_status=statuses.get,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
plugins = facade.installed()
assert [plugin.id for plugin in plugins] == [
"ActivePlugin",
"DependencyPending",
"SourceMissing",
]
assert [plugin.runtime_status for plugin in plugins] == [
PluginRuntimeStatus.ACTIVE,
PluginRuntimeStatus.DEPENDENCY_PENDING,
PluginRuntimeStatus.SOURCE_MISSING,
]
assert plugins[1].plugin_name == "DependencyPending"
assert plugins[2].installed is True
+30
View File
@@ -27,6 +27,36 @@ def _write_pyproject(root: Path, plugin_id: str, content: str) -> Path:
return plugin_dir
def test_classify_plugins_preserves_ids_and_separates_startup_paths(
tmp_path,
monkeypatch,
):
"""启动分类保留规范插件 ID,并区分可加载、缺依赖和缺源码。"""
plugin_root = tmp_path / "plugins"
(plugin_root / "readyplugin").mkdir(parents=True)
_write_requirements(plugin_root, "DependencyPending", "demo>=2\n")
installer = PluginDependencyInstaller(
Mock(),
installed_plugins_provider=lambda: [
"ReadyPlugin",
"DependencyPending",
"SourcePending",
],
plugin_dir=plugin_root,
)
monkeypatch.setattr(
installer,
"_installed_packages",
lambda: {"demo": Version("1.0")},
)
ready, missing_dependencies, missing_source = installer.classify_plugins()
assert ready == ["ReadyPlugin"]
assert missing_dependencies == ["DependencyPending"]
assert missing_source == ["SourcePending"]
def test_find_missing_merges_only_installed_plugin_constraints(tmp_path, monkeypatch):
"""依赖扫描只覆盖安装清单,并合并同名包的多插件约束。"""
plugin_root = tmp_path / "plugins"
+37
View File
@@ -0,0 +1,37 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.runtime.extensions.plugin.dependency import PluginDependencyService
def test_install_missing_skips_installer_when_environment_is_satisfied() -> None:
"""依赖均满足时只执行轻量检查,不进入包安装链。"""
installer = SimpleNamespace(
find_missing=MagicMock(return_value=[]),
install=MagicMock(),
)
service = PluginDependencyService(
system=lambda: SimpleNamespace(dependency=installer),
log=MagicMock(),
)
result = service.install_missing_with_status()
assert result.success is True
assert result.missing == []
installer.install.assert_not_called()
def test_install_missing_preserves_list_return_contract() -> None:
"""旧入口继续返回缺失项列表,供现有调用方按真值判断。"""
installer = SimpleNamespace(
find_missing=MagicMock(return_value=["demo>=1"]),
install=MagicMock(return_value=(True, "")),
)
service = PluginDependencyService(
system=lambda: SimpleNamespace(dependency=installer),
log=MagicMock(),
)
assert service.install_missing() == ["demo>=1"]
installer.install.assert_called_once_with(["demo>=1"])
+39
View File
@@ -6,11 +6,14 @@ from app import schemas
from app.api.endpoints.plugin import plugin_history
from app.api.endpoints.plugin import plugin_releases
from app.api.endpoints.plugin import reset_plugin
from app.api.endpoints.plugin import reload_plugin
from app.api.endpoints.plugin import runtime_status
from app.api.endpoints.system import sync_plugin_market_from_wiki
from app.application.plugin.config import PluginConfigCommand
from app.runtime.config import settings
from app.runtime.extensions.plugin_manager import PluginManager
from app.schemas.event import PluginDataResetEventData
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import ChainEventType
from app.foundation.singleton import Singleton
@@ -48,6 +51,42 @@ def test_plugin_history_merges_remote_metadata():
assert result.has_update
def test_runtime_status_reports_pending_and_terminal_counts():
"""插件页摘要区分后台收敛、准备态和终态失败。"""
plugin_manager = MagicMock()
plugin_manager.get_plugin_runtime_statuses.return_value = {
"SourcePending": PluginRuntimeStatus.SOURCE_MISSING,
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
"ActivePlugin": PluginRuntimeStatus.ACTIVE,
"FailedPlugin": PluginRuntimeStatus.LOAD_FAILED,
}
plugin_manager.is_plugin_settling.return_value = True
plugin_manager.get_plugin_runtime_generation.return_value = 7
with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager):
result = asyncio.run(runtime_status(None))
assert result.ready is False
assert result.generation == 7
assert result.pending_count == 2
assert result.failed_count == 1
def test_reload_endpoint_reports_load_failure(monkeypatch):
"""插件重载失败时接口返回失败,同时仍刷新旧注册投影。"""
plugin_manager = MagicMock()
plugin_manager.reload_plugin.return_value = PluginRuntimeStatus.LOAD_FAILED
register = MagicMock()
monkeypatch.setattr(plugin_endpoint, "PluginManager", lambda: plugin_manager)
monkeypatch.setattr(plugin_endpoint, "register_plugin", register)
result = reload_plugin("DemoPlugin", None)
assert result.success is False
assert result.message == "插件加载失败,请查看插件日志"
register.assert_called_once_with("DemoPlugin")
def test_plugin_history_returns_installed_plugin_when_remote_missing():
"""
远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。
+2
View File
@@ -60,6 +60,8 @@ async def test_install_failure_stops_before_report_persistence_and_reload():
assert result.failure_stage == "package_install"
assert result.rollback.file_restored is True
assert result.rollback.dependency_supported is False
assert "插件文件已恢复" not in result.message
assert "Python依赖变更不支持自动回滚" not in result.message
rollback.assert_awaited_once()
reporter.assert_not_awaited()
writer.assert_not_awaited()
+100
View File
@@ -0,0 +1,100 @@
"""插件生命周期六类状态中的运行结果测试。"""
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.schemas.plugin import PluginRuntimeStatus
def _plugin_class(*, init_error: Exception | None = None):
"""构造满足插件最小生命周期合同的测试类。"""
class DemoPlugin:
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
if init_error:
raise init_error
@staticmethod
def get_state():
return True
return DemoPlugin
def _lifecycle(*, plugins, auth=True):
"""构造隔离外部事件和模块清理的生命周期实例。"""
classes = {}
running = {}
statuses = {}
lifecycle = PluginLifecycle(
classes=classes,
running=running,
load_plugins=lambda _plugin_id, _installed, _check: list(plugins),
installed_plugins=lambda: ["DemoPlugin"],
plugin_config=lambda _plugin_id: {},
auth_checker=lambda _plugin: auth,
clear_modules=MagicMock(),
clear_tools=MagicMock(),
enable_events=MagicMock(),
disable_events=MagicMock(),
runtime_status_writer=statuses.__setitem__,
log=MagicMock(),
event_sender=MagicMock(),
)
return lifecycle, classes, running, statuses
def test_lifecycle_records_active_result():
"""插件完成构造和初始化后进入 active。"""
lifecycle, classes, running, statuses = _lifecycle(
plugins=[_plugin_class()],
)
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.ACTIVE}
assert "DemoPlugin" in classes
assert "DemoPlugin" in running
assert statuses["DemoPlugin"] is PluginRuntimeStatus.ACTIVE
def test_lifecycle_records_policy_block_without_runtime_instance():
"""类已发现但权限策略拒绝时进入 blocked_by_policy。"""
lifecycle, _classes, running, statuses = _lifecycle(
plugins=[_plugin_class()],
auth=False,
)
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.BLOCKED_BY_POLICY}
assert running == {}
assert statuses["DemoPlugin"] is PluginRuntimeStatus.BLOCKED_BY_POLICY
def test_lifecycle_records_load_failure_for_init_exception():
"""插件初始化异常时保留类信息并进入 load_failed。"""
lifecycle, classes, running, statuses = _lifecycle(
plugins=[_plugin_class(init_error=RuntimeError("init failed"))],
)
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.LOAD_FAILED}
assert "DemoPlugin" in classes
assert running == {}
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
def test_lifecycle_records_load_failure_when_loader_returns_no_class():
"""目标源码无法产生合法插件类时进入 load_failed。"""
lifecycle, _classes, running, statuses = _lifecycle(plugins=[])
result = lifecycle.start("DemoPlugin")
assert result == {"DemoPlugin": PluginRuntimeStatus.LOAD_FAILED}
assert running == {}
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
+331 -4
View File
@@ -1,12 +1,23 @@
import asyncio
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.foundation.singleton import Singleton
from app.runtime.extensions.plugin.monitor import PluginMonitorController
from app.runtime.extensions.plugin.dependency import (
PluginDependencyClassification,
PluginDependencyInstallResult,
)
from app.runtime.extensions.plugin.monitor import (
PluginChangeMonitor,
PluginMonitorController,
)
from app.runtime.extensions.plugin.system import reset_plugin_system
from app.runtime.extensions.plugin_manager import PluginManager
from app.schemas.plugin import PluginRuntimeStatus
from app.startup import plugins_initializer
@@ -45,10 +56,15 @@ def test_plugin_manager_constructor_does_not_start_monitor_before_runtime(
def test_init_plugins_starts_monitor_after_runtime_and_routes(monkeypatch) -> None:
"""插件运行时和动态路由就绪后,启动层才允许文件监控接收变化"""
"""启动阶段只加载依赖已就绪的插件,再开放路由和文件监控"""
order: list[str] = []
manager = MagicMock()
manager.start.side_effect = lambda: order.append("plugins")
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("ReadyPlugin",),
missing_dependencies=("DependencyPending",),
missing_source=("SourcePending",),
)
manager.start.side_effect = lambda plugin_id: order.append(f"plugin:{plugin_id}")
manager.start_monitor.side_effect = lambda: order.append("monitor")
monkeypatch.setattr(
plugins_initializer,
@@ -64,7 +80,238 @@ def test_init_plugins_starts_monitor_after_runtime_and_routes(monkeypatch) -> No
plugins_initializer.init_plugins()
assert order == ["services", "plugins", "routes", "monitor"]
assert order == ["services", "plugin:ReadyPlugin", "routes", "monitor"]
manager.set_plugin_settling.assert_called_once_with(True)
def test_plugin_manager_projects_dependency_classification_to_runtime_status() -> None:
"""真实管理器按分类字段写入三类启动状态,避免测试替身掩盖字段漂移。"""
_reset_plugin_manager()
manager = PluginManager()
manager.apply_plugin_dependency_classification(
PluginDependencyClassification(
ready=("ReadyPlugin",),
missing_dependencies=("DependencyPending",),
missing_source=("SourcePending",),
)
)
assert manager.get_plugin_runtime_statuses() == {
"ReadyPlugin": PluginRuntimeStatus.READY,
"DependencyPending": PluginRuntimeStatus.DEPENDENCY_PENDING,
"SourcePending": PluginRuntimeStatus.SOURCE_MISSING,
}
_reset_plugin_manager()
def test_plugin_manager_promotes_running_dependency_after_recovery() -> None:
"""依赖恢复后,运行中的插件状态必须允许后台流程触发重载。"""
_reset_plugin_manager()
manager = PluginManager()
manager._plugin_registry.running["DependencyRecovered"] = object()
manager._plugin_registry.set_runtime_status(
"DependencyRecovered",
PluginRuntimeStatus.DEPENDENCY_PENDING,
)
manager.apply_plugin_dependency_classification(
PluginDependencyClassification(
ready=("DependencyRecovered",),
missing_dependencies=(),
missing_source=(),
)
)
assert manager.get_plugin_runtime_statuses()["DependencyRecovered"] is (
PluginRuntimeStatus.READY
)
_reset_plugin_manager()
def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock:
"""隔离后台执行器并返回动态路由注册替身。"""
async def execute(_loop, task_func, _task_name):
return task_func()
register = MagicMock()
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
monkeypatch.setattr(plugins_initializer, "register_plugin_api", register)
manager.get_plugin_runtime_statuses.return_value = {}
return register
@pytest.mark.asyncio
async def test_sync_plugins_activates_ready_plugins_when_dependencies_fail(
monkeypatch,
) -> None:
"""依赖恢复失败时仍激活无关的已就绪插件。"""
manager = MagicMock()
manager.sync.return_value = ["demo"]
manager.install_plugin_missing_dependencies_with_status.return_value = (
PluginDependencyInstallResult(missing=["demo>=1"], success=False)
)
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("ReadyPlugin",),
missing_dependencies=("DependencyPending",),
missing_source=(),
)
manager.running_plugins = {}
register = _patch_sync_plugins(monkeypatch, manager)
assert await plugins_initializer.sync_plugins() is True
manager.start.assert_called_once_with("ReadyPlugin")
manager.reload_plugin.assert_not_called()
register.assert_called_once_with("ReadyPlugin")
@pytest.mark.asyncio
async def test_sync_plugins_loads_only_plugins_that_become_ready(
monkeypatch,
) -> None:
"""后台依赖恢复后只启动尚未运行且当前已就绪的插件。"""
manager = MagicMock()
manager.sync.return_value = []
manager.install_plugin_missing_dependencies_with_status.return_value = (
PluginDependencyInstallResult(missing=["demo>=1"], success=True)
)
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("ReadyPlugin", "DependencyRecovered"),
missing_dependencies=(),
missing_source=("SourcePending",),
)
running = {"ReadyPlugin": object()}
manager.running_plugins = running
def start(plugin_id: str) -> None:
running[plugin_id] = object()
manager.start.side_effect = start
register = _patch_sync_plugins(monkeypatch, manager)
assert await plugins_initializer.sync_plugins() is True
manager.start.assert_called_once_with("DependencyRecovered")
manager.reload_plugin.assert_not_called()
register.assert_called_once_with("DependencyRecovered")
@pytest.mark.asyncio
async def test_sync_plugins_reloads_only_updated_running_plugins(monkeypatch) -> None:
"""源码同步只重载对应运行实例,不重启其他插件。"""
manager = MagicMock()
manager.sync.return_value = ["UpdatedPlugin"]
manager.install_plugin_missing_dependencies_with_status.return_value = (
PluginDependencyInstallResult(missing=[], success=True)
)
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("StablePlugin", "UpdatedPlugin"),
missing_dependencies=(),
missing_source=(),
)
manager.running_plugins = {
"StablePlugin": object(),
"UpdatedPlugin": object(),
}
register = _patch_sync_plugins(monkeypatch, manager)
assert await plugins_initializer.sync_plugins() is True
manager.reload_plugin.assert_called_once_with("UpdatedPlugin")
manager.start.assert_not_called()
register.assert_called_once_with("UpdatedPlugin")
@pytest.mark.asyncio
async def test_sync_plugins_reloads_running_plugin_after_dependency_recovery(
monkeypatch,
) -> None:
"""依赖恢复后,已运行的旧实例必须切换到新源码。"""
manager = MagicMock()
manager.sync.return_value = []
manager.install_plugin_missing_dependencies_with_status.return_value = (
PluginDependencyInstallResult(missing=["demo>=1"], success=True)
)
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("DependencyRecovered",),
missing_dependencies=(),
missing_source=(),
)
manager.running_plugins = {"DependencyRecovered": object()}
register = _patch_sync_plugins(monkeypatch, manager)
manager.get_plugin_runtime_statuses.return_value = {
"DependencyRecovered": PluginRuntimeStatus.DEPENDENCY_PENDING,
}
assert await plugins_initializer.sync_plugins() is True
manager.reload_plugin.assert_called_once_with("DependencyRecovered")
manager.start.assert_not_called()
register.assert_called_once_with("DependencyRecovered")
@pytest.mark.asyncio
async def test_sync_plugins_keeps_runtime_when_nothing_changed(monkeypatch) -> None:
"""源码和依赖均无变化时保留首次初始化结果。"""
manager = MagicMock()
manager.sync.return_value = []
manager.install_plugin_missing_dependencies_with_status.return_value = (
PluginDependencyInstallResult(missing=[], success=True)
)
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("ReadyPlugin",),
missing_dependencies=(),
missing_source=(),
)
manager.running_plugins = {"ReadyPlugin": object()}
register = _patch_sync_plugins(monkeypatch, manager)
assert await plugins_initializer.sync_plugins() is False
manager.start.assert_not_called()
manager.reload_plugin.assert_not_called()
register.assert_not_called()
@pytest.mark.asyncio
async def test_sync_plugins_keeps_event_loop_responsive_during_activation(
monkeypatch,
) -> None:
"""插件初始化运行在线程池时,Web 事件循环仍可继续调度。"""
manager = MagicMock()
manager.sync.return_value = []
manager.install_plugin_missing_dependencies_with_status.return_value = (
PluginDependencyInstallResult(missing=[], success=True)
)
manager.classify_plugins.return_value = PluginDependencyClassification(
ready=("SlowPlugin",),
missing_dependencies=(),
missing_source=(),
)
manager.running_plugins = {}
activation_started = threading.Event()
def slow_start(_plugin_id: str) -> None:
activation_started.set()
time.sleep(0.1)
manager.start.side_effect = slow_start
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock())
monkeypatch.setattr(
plugins_initializer.global_vars,
"CURRENT_EVENT_LOOP",
asyncio.get_running_loop(),
)
sync_task = asyncio.create_task(plugins_initializer.sync_plugins())
assert await asyncio.to_thread(activation_started.wait, 1)
assert sync_task.done() is False
assert await sync_task is True
@pytest.mark.parametrize(
@@ -98,6 +345,86 @@ def test_start_monitor_respects_runtime_configuration(
_reset_plugin_manager()
def test_plugin_monitor_waits_until_dependency_settlement(monkeypatch) -> None:
"""后台依赖收敛期间不启动文件监控,避免源码写入触发重复重载。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=True,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
)
manager = PluginManager()
start = MagicMock()
reload_monitor = MagicMock()
manager._plugin_monitor.start = start
manager._plugin_monitor.reload = reload_monitor
manager.set_plugin_settling(True)
manager.start_monitor()
manager.reload_monitor()
start.assert_not_called()
reload_monitor.assert_called_once_with(enabled=False)
manager.set_plugin_settling(False)
manager.start_monitor()
start.assert_called_once_with()
_reset_plugin_manager()
def test_plugin_monitor_skips_installing_plugin_until_package_write_finishes(tmp_path) -> None:
"""安装替换目录期间,文件事件不得抢先导入未完成的插件包。"""
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=lambda plugin_id: plugin_id.lower() == "demoplugin",
)
monitor._process_changes({("modified", str(tmp_path / "demo" / "plugin.py"))})
reload_plugin.assert_not_called()
def test_plugin_monitor_suppression_is_reference_counted(monkeypatch) -> None:
"""同一插件的重叠写入必须等最后一个事务退出后才解除监控抑制。"""
_reset_plugin_manager()
reset_plugin_system()
monkeypatch.setattr(
"app.runtime.extensions.plugin_manager.settings",
SimpleNamespace(
DEV=False,
PLUGIN_AUTO_RELOAD=False,
ROOT_PATH=MagicMock(),
),
)
manager = PluginManager()
with manager.suppress_plugin_monitor("DemoPlugin"):
assert manager.is_plugin_monitor_suppressed("demoplugin") is True
with manager.suppress_plugin_monitor("demoplugin"):
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
_reset_plugin_manager()
def test_config_change_reloads_monitor(monkeypatch) -> None:
"""配置热更新继续使用重建语义,不复用首次启动入口。"""
_reset_plugin_manager()
+22
View File
@@ -1,6 +1,7 @@
from types import SimpleNamespace
from app.runtime.extensions.plugin.registry import PluginRegistry
from app.schemas.plugin import PluginRuntimeStatus
def test_registry_owns_classes_instances_and_stable_snapshots():
@@ -36,3 +37,24 @@ def test_registry_clear_preserves_compatibility_mapping_identity():
assert registry.running is running
assert classes == {}
assert running == {}
def test_registry_tracks_runtime_status_generation_and_settling():
"""状态与后台收敛变化只在真实改变时推进刷新代次。"""
registry = PluginRegistry()
registry.set_runtime_status("Demo", PluginRuntimeStatus.READY)
first_generation = registry.generation
registry.set_runtime_status("Demo", PluginRuntimeStatus.READY)
registry.set_settling(True)
assert registry.runtime_status("Demo") is PluginRuntimeStatus.READY
assert registry.runtime_status_snapshot() == {
"Demo": PluginRuntimeStatus.READY,
}
assert registry.generation == first_generation + 1
assert registry.settling is True
registry.remove("Demo")
assert registry.runtime_status("Demo") is None
+66
View File
@@ -0,0 +1,66 @@
import asyncio
from concurrent.futures import Future
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.startup import lifecycle
@pytest.mark.asyncio
async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch) -> None:
"""插件 ready 只在调度任务和命令注册完成后对外可见。"""
order: list[str] = []
manager = MagicMock()
command_future = Future()
async def sync_plugins() -> bool:
order.append("plugins")
return True
async def execute_task(_loop, task_func, _task_name):
task_func()
return []
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", False)
monkeypatch.setattr(lifecycle, "get_plugin_manager", lambda: manager)
monkeypatch.setattr(lifecycle, "sync_plugins", sync_plugins)
monkeypatch.setattr(lifecycle, "execute_task", execute_task)
monkeypatch.setattr(
lifecycle,
"init_plugin_scheduler",
lambda: order.append("scheduler"),
)
monkeypatch.setattr(
lifecycle,
"restart_command",
lambda: (order.append("commands"), command_future)[1],
)
monkeypatch.setattr(lifecycle, "SystemHelper", MagicMock())
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock())
monkeypatch.setattr(
lifecycle.MoviePilotServerHelper,
"async_report_usage",
AsyncMock(),
)
manager.set_plugin_settling.side_effect = lambda value: order.append(
f"settling:{value}"
)
manager.start_monitor.side_effect = lambda: order.append("monitor")
settle_task = asyncio.create_task(lifecycle.init_extra())
await asyncio.sleep(0)
assert order == ["plugins", "scheduler", "commands"]
manager.set_plugin_settling.assert_not_called()
command_future.set_result(None)
await settle_task
assert order == [
"plugins",
"scheduler",
"commands",
"settling:False",
"monitor",
]