From 023835107147d4050ec62c0a2bef36a2e55676a3 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:35:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20v3t=20=E4=B8=8B=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9A=90=E8=97=8F=E4=B8=8D=E5=85=BC=E5=AE=B9=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=94=B9=E4=B8=BA=E6=8F=90=E7=A4=BA=E4=B8=8D?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20(#6760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugin): surface v3t incompatibility instead of hiding plugins Co-Authored-By: Claude Opus 5 * chore(architecture): refresh dependency baseline after plugin gate split Co-Authored-By: Claude Opus 5 * fix(plugin): keep runtime-incompatible plugins out of startup sync Co-Authored-By: Claude Opus 5 * fix(plugin): annotate runtime compatibility ports without bare dict Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- app/adapters/external/market.py | 5 ++ app/adapters/external/plugin/client.py | 83 +++++++++++++++++-- app/adapters/system/plugin/package.py | 16 +++- app/api/endpoints/plugin.py | 17 ++-- app/application/plugin/inventory.py | 3 - app/application/plugin/management.py | 3 + app/application/plugin/release.py | 5 ++ app/domain/plugin.py | 36 +++++++- app/runtime/extensions/plugin/lifecycle.py | 10 ++- app/runtime/extensions/plugin/loader.py | 40 ++++++++- app/runtime/extensions/plugin/metadata.py | 10 ++- app/runtime/extensions/plugin/runtime.py | 13 +++ app/runtime/extensions/plugin/sync.py | 9 +- app/runtime/extensions/plugin/system.py | 11 ++- app/schemas/plugin.py | 6 ++ app/startup/initializers/plugins.py | 2 +- docs/architecture-overview.md | 2 +- docs/refactor/optimization-checklist.md | 2 +- docs/v3t-runtime-governance.md | 17 ++++ .../architecture/dependency-baseline.json | 6 +- tests/test_plugin_candidate_inventory.py | 16 ++-- tests/test_plugin_catalog_service.py | 1 + tests/test_plugin_database_lifecycle.py | 1 + tests/test_plugin_helper.py | 50 ++++++++++- tests/test_plugin_helper_governance.py | 33 +++++++- ...plugin_instance_log_context_entrypoints.py | 1 + tests/test_plugin_lifecycle_concurrency.py | 1 + tests/test_plugin_lifecycle_status.py | 16 ++++ tests/test_plugin_local_sync.py | 35 ++++++++ tests/test_plugin_sync_service.py | 76 +++++++++++++++++ tests/test_plugin_virtual_instances.py | 40 +++++++++ 31 files changed, 514 insertions(+), 52 deletions(-) diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 987d3cf1d..f24bb522e 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -128,6 +128,11 @@ class PluginHelper(metaclass=WeakSingleton): is_package_plugin_compatible = staticmethod(PluginMarketTransport.is_package_plugin_compatible) check_plugin_system_version = staticmethod(PluginMarketTransport.check_plugin_system_version) annotate_plugin_system_version = staticmethod(PluginMarketTransport.annotate_plugin_system_version) + check_plugin_runtime_compatibility = staticmethod(PluginMarketTransport.check_plugin_runtime_compatibility) + annotate_plugin_runtime_compatibility = staticmethod( + PluginMarketTransport.annotate_plugin_runtime_compatibility + ) + check_plugin_install_compatibility = staticmethod(PluginMarketTransport.check_plugin_install_compatibility) get_local_repo_paths = staticmethod(PluginMarketTransport.get_local_repo_paths) get_local_plugin_candidates = staticmethod(_plugin_market_transport.get_local_plugin_candidates) get_local_plugin_candidate = staticmethod(_plugin_market_transport.get_local_plugin_candidate) diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index 84150e7f7..f27fdf119 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -22,6 +22,7 @@ from requests import Response from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.domain.plugin import ( build_local_plugin_source, + check_plugin_runtime_compatibility, check_plugin_system_version, is_local_plugin_source, is_physical_plugin_id, @@ -306,8 +307,6 @@ class PluginMarketTransport(metaclass=WeakSingleton): """ if not isinstance(plugin_info, dict): return False - if is_free_threaded_runtime() and plugin_info.get("v3t") is False: - return False if not get_runtime_setting('VERSION_FLAG'): return True current_flag = get_runtime_setting('VERSION_FLAG') @@ -336,8 +335,6 @@ class PluginMarketTransport(metaclass=WeakSingleton): """ if not isinstance(plugin_info, dict): return False - if is_free_threaded_runtime() and plugin_info.get("v3t") is False: - return False current_flag = get_runtime_setting('VERSION_FLAG') if not current_flag: return not package_version @@ -415,6 +412,48 @@ class PluginMarketTransport(metaclass=WeakSingleton): f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装" ) + @staticmethod + def check_plugin_runtime_compatibility( + plugin_info: Optional[PluginPayload], + ) -> tuple[bool, str]: + """ + 检查插件声明与当前解释器运行时是否兼容,返回兼容状态和用户可读原因。 + """ + return check_plugin_runtime_compatibility( + plugin_info, + free_threaded=is_free_threaded_runtime(), + ) + + @classmethod + def annotate_plugin_runtime_compatibility( + cls, plugin_info: PluginPayload + ) -> PluginPayload: + """ + 为插件 package 元数据补充运行时兼容状态,便于市场展示和安装流程复用。 + """ + if not isinstance(plugin_info, dict): + return plugin_info + + compatible, message = cls.check_plugin_runtime_compatibility(plugin_info) + plugin_info["runtime_compatible"] = compatible + plugin_info["runtime_message"] = message + return plugin_info + + @classmethod + def check_plugin_install_compatibility( + cls, plugin_info: Optional[PluginPayload] + ) -> tuple[bool, str]: + """ + 安装准入的唯一兼容判据:先判运行时,再判主程序版本。 + + 运行时不兼容先于版本判断返回,否则 v3t 上会把"该插件不支持 free-threaded" + 报成一条与版本有关的提示。 + """ + compatible, message = cls.check_plugin_runtime_compatibility(plugin_info) + if not compatible: + return compatible, message + return cls.check_plugin_system_version(plugin_info) + @classmethod def annotate_plugin_system_version( cls, plugin_info: PluginPayload @@ -531,6 +570,7 @@ class PluginMarketTransport(metaclass=WeakSingleton): package_version or None, ) self.annotate_plugin_system_version(candidate) + self.annotate_plugin_runtime_compatibility(candidate) candidate_version = str(candidate.get("version") or "0") existing = candidates.get(pid) @@ -606,7 +646,13 @@ class PluginMarketTransport(metaclass=WeakSingleton): f"插件索引条目不兼容 {get_runtime_setting('VERSION_FLAG')}" ) self.annotate_plugin_system_version(candidate) - if strict_system_version and candidate.get("system_version_compatible") is False: + self.annotate_plugin_runtime_compatibility(candidate) + # 运行时不兼容与代际、版本不兼容一样要投影为不可安装候选: + # 本地热同步只认 compatible 位,漏掉这一位会让每次源码变更都发起一次必败安装。 + if candidate.get("runtime_compatible") is False: + candidate["compatible"] = False + candidate["skip_reason"] = candidate.get("runtime_message") + elif strict_system_version and candidate.get("system_version_compatible") is False: candidate["compatible"] = False candidate["skip_reason"] = candidate.get("system_version_message") elif not strict_system_version and is_compatible: @@ -1904,6 +1950,17 @@ class PluginMarketClient: plugin_info["system_version_message"] = message return plugin_info + @staticmethod + def annotate_runtime_compatibility(plugin_info: PluginPayload) -> PluginPayload: + """补充插件声明的运行时兼容状态。""" + compatible, message = check_plugin_runtime_compatibility( + plugin_info, + free_threaded=is_free_threaded_runtime(), + ) + plugin_info["runtime_compatible"] = compatible + plugin_info["runtime_message"] = message + return plugin_info + @staticmethod def is_package_compatible( plugin_info: PluginPayload, @@ -1914,7 +1971,6 @@ class PluginMarketClient: plugin_info, package_version, current_generation=get_runtime_setting('VERSION_FLAG'), - free_threaded=is_free_threaded_runtime(), ) @@ -1988,6 +2044,21 @@ class PluginPackageSourceClient: plugin_info, current_version=get_app_version() ) + @staticmethod + def check_plugin_install_compatibility( + plugin_info: PluginPayload, + ) -> tuple[bool, str]: + """安装准入的唯一兼容判据:先判运行时,再判宿主版本。""" + compatible, message = check_plugin_runtime_compatibility( + plugin_info, + free_threaded=is_free_threaded_runtime(), + ) + if not compatible: + return compatible, message + return check_plugin_system_version( + plugin_info, current_version=get_app_version() + ) + def get_plugin_package_version( self, plugin_id: str, diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index b13ba50de..502c2631a 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -101,6 +101,11 @@ class PluginPackageSourcePort(Protocol): ) -> tuple[bool, str]: """校验插件声明的宿主版本约束。""" + def check_plugin_install_compatibility( + self, plugin_info: dict[str, Any] + ) -> tuple[bool, str]: + """校验插件与当前运行时和宿主版本是否都允许安装。""" + def get_plugin_package_version( self, plugin_id: str, repo_url: str, package_version: Optional[str] ) -> Optional[str]: @@ -729,6 +734,12 @@ class PluginPackageManager: """委托外部来源客户端校验宿主版本约束。""" return self._require_source().check_plugin_system_version(plugin_info) + def check_plugin_install_compatibility( + self, plugin_info: dict[str, Any] + ) -> tuple[bool, str]: + """委托外部来源客户端校验运行时与宿主版本约束。""" + return self._require_source().check_plugin_install_compatibility(plugin_info) + def get_plugin_package_version( self, plugin_id: str, repo_url: str, package_version: Optional[str] ) -> Optional[str]: @@ -1134,9 +1145,10 @@ class PluginPackageManager: ) if not candidate: return False, f"未找到本地插件:{pid}" - compatible, message = self.check_plugin_system_version(candidate) + # 本地插件包走同一条准入判据:运行时不兼容要如实报,不能落到版本提示上 + compatible, message = self.check_plugin_install_compatibility(candidate) if not compatible: - logger.debug(f"{pid} 本地插件系统版本兼容性检查失败:{message}") + logger.debug(f"{pid} 本地插件安装兼容性检查失败:{message}") return False, message raw_source_path = candidate.get("path") diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 89b6cdb5d..3c51f0041 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -251,6 +251,7 @@ async def runtime_status( _SchemaPluginRuntimeStatus.SYNC_FAILED, _SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY, _SchemaPluginRuntimeStatus.LOAD_FAILED, + _SchemaPluginRuntimeStatus.INCOMPATIBLE_RUNTIME, } return _SchemaPluginRuntimeSummary( ready=not plugin_manager.is_plugin_settling(), @@ -394,14 +395,14 @@ def reload_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_s return _SchemaResponse(success=False, message=str(error)) if runtime_status is _SchemaPluginRuntimeStatus.ACTIVE: return _SchemaResponse(success=True) - return _SchemaResponse( - success=False, - message=( - "未通过用户认证,请查看日志" - if runtime_status is _SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY - else "插件加载失败,请查看插件日志" - ), - ) + if runtime_status is _SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY: + message = "未通过用户认证,请查看日志" + elif runtime_status is _SchemaPluginRuntimeStatus.INCOMPATIBLE_RUNTIME: + # 与插件卡片保持同一说法:重载不会改变载荷自身的运行时声明 + message = "插件声明不支持当前运行环境,请查看日志" + else: + message = "插件加载失败,请查看插件日志" + return _SchemaResponse(success=False, message=message) @router.get( diff --git a/app/application/plugin/inventory.py b/app/application/plugin/inventory.py index d4643b3be..4d9b5aab6 100644 --- a/app/application/plugin/inventory.py +++ b/app/application/plugin/inventory.py @@ -21,7 +21,6 @@ from app.application.plugin.source import ( normalize_package_generation, ) from app.domain.plugin import is_plugin_generation_compatible -from app.foundation.environment import is_free_threaded_runtime PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1") PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]] @@ -358,7 +357,6 @@ def _market_candidates( dict(raw_info), None if package_generation == "v1" else package_generation, current_generation="v3", - free_threaded=is_free_threaded_runtime(), ): continue plugin_version = raw_info.get("version") @@ -399,7 +397,6 @@ def _local_candidate( dict(plugin_info), None if generation == "v1" else generation, current_generation="v3", - free_threaded=is_free_threaded_runtime(), ): return None repo_url = plugin_info.get("repo_url") diff --git a/app/application/plugin/management.py b/app/application/plugin/management.py index 5ee0dff97..71b30e5d5 100644 --- a/app/application/plugin/management.py +++ b/app/application/plugin/management.py @@ -88,12 +88,15 @@ def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any: if repo_url: setattr(plugin, "repo_url", repo_url) + # 运行时兼容状态与版本兼容状态同属安装准入依据,已安装卡片据此禁用更新入口 for attr in ( "has_update", "release", "system_version", "system_version_compatible", "system_version_message", + "runtime_compatible", + "runtime_message", ): value = getattr(source_plugin, attr, None) if value is not None: diff --git a/app/application/plugin/release.py b/app/application/plugin/release.py index b7bff5317..ef8c00144 100644 --- a/app/application/plugin/release.py +++ b/app/application/plugin/release.py @@ -220,6 +220,11 @@ def _merge_market_metadata(plugin: Plugin, market_plugin: Plugin) -> Plugin: market_plugin.system_version_message or plugin.system_version_message ), + # 版本历史弹窗的"更新到最新版"同样按运行时兼容状态禁用并说明原因 + "runtime_compatible": market_plugin.runtime_compatible, + "runtime_message": ( + market_plugin.runtime_message or plugin.runtime_message + ), } ), ) diff --git a/app/domain/plugin.py b/app/domain/plugin.py index c6b399943..7e619c98f 100644 --- a/app/domain/plugin.py +++ b/app/domain/plugin.py @@ -13,6 +13,8 @@ from packaging.version import InvalidVersion, Version LOCAL_PLUGIN_SOURCE_PREFIX = "local://" PLUGIN_SYSTEM_VERSION_FIELD = "system_version" +# free-threaded 运行时(v3t)专用的插件声明字段;缺省视为兼容,只有显式 false 才拒绝 +PLUGIN_FREE_THREADED_FIELD = "v3t" _PHYSICAL_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,127}$") PLUGIN_GENERATION_COMPATIBILITY: dict[str, tuple[str, ...]] = { "v3": ("v2",), @@ -158,13 +160,16 @@ def is_plugin_generation_compatible( package_generation: str | None, *, current_generation: str | None, - free_threaded: bool, ) -> bool: - """按当前代际、兼容代际和基础索引规则判断一个市场条目。""" + """按当前代际、兼容代际和基础索引规则判断一个市场条目。 + + 只回答代际问题。运行时(free-threaded)兼容性由 + :func:`check_plugin_runtime_compatibility` 单独判断并标注给前端, + 不在这里把条目从目录里剔除——用户需要看到插件和不可用的原因, + 而不是插件凭空消失。 + """ if not isinstance(plugin_info, Mapping): return False - if free_threaded and plugin_info.get("v3t") is False: - return False if not current_generation: return not package_generation if plugin_info.get(current_generation) is False: @@ -187,6 +192,29 @@ def is_plugin_generation_compatible( ) +def check_plugin_runtime_compatibility( + plugin_info: object, + *, + free_threaded: bool, +) -> tuple[bool, str]: + """检查插件声明与当前解释器运行时是否兼容,返回兼容状态和用户可读原因。 + + 公开可调用:市场标注、安装准入和运行目录加载共用这一条判据,避免三处各写一份 + 规则后出现"市场说能装、准入放行、加载又跳过"的分裂。当前只有 free-threaded + 一个维度,未声明该字段的插件一律保持兼容。 + :param plugin_info: 插件 package 条目 + :param free_threaded: 当前是否运行在 free-threaded 解释器上 + """ + if not isinstance(plugin_info, Mapping): + return True, "" + if free_threaded and plugin_info.get(PLUGIN_FREE_THREADED_FIELD) is False: + return False, ( + "插件声明不支持 free-threaded 运行时(v3t)," + "请改用同版本标准 V3 镜像安装" + ) + return True, "" + + def check_plugin_system_version( plugin_info: object, *, diff --git a/app/runtime/extensions/plugin/lifecycle.py b/app/runtime/extensions/plugin/lifecycle.py index 3bf060e58..8c6e6fb64 100644 --- a/app/runtime/extensions/plugin/lifecycle.py +++ b/app/runtime/extensions/plugin/lifecycle.py @@ -71,6 +71,7 @@ class PluginLifecycle: enable_events: Callable[[Any], None], disable_events: Callable[[Any], None], runtime_status_writer: Callable[[str, PluginRuntimeStatus], None], + runtime_compatible: Callable[[str], bool], database: Callable[[], PluginDatabase], log: Any, event_sender: Callable[..., Any], @@ -89,6 +90,7 @@ class PluginLifecycle: self._enable_events = enable_events self._disable_events = disable_events self._runtime_status_writer = runtime_status_writer + self._runtime_compatible = runtime_compatible self._database = database self._logger = log self._event_sender = event_sender @@ -177,7 +179,13 @@ class PluginLifecycle: for result_id in results ): self._remove_classification(plugin_id) - status = PluginRuntimeStatus.LOAD_FAILED + # 装载结果为空有两种原因:载荷声明与当前运行时不兼容被加载器跳过, + # 或者真的导入失败。前者在卡片上要说明不支持,不能报成加载失败。 + status = ( + PluginRuntimeStatus.LOAD_FAILED + if self._runtime_compatible(plugin_id) + else PluginRuntimeStatus.INCOMPATIBLE_RUNTIME + ) self._runtime_status_writer(plugin_id, status) results[plugin_id] = status self._clear_tools() diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index 352ee8ba3..289347451 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -14,12 +14,13 @@ from typing import Any, Optional from app.foundation.environment import is_free_threaded_runtime from app.runtime.settings import get_runtime_setting -from app.schemas.plugin import PluginInstance +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus PluginImportPreparer = Callable[..., None] PluginImportScanner = Callable[..., None] PluginValidator = Callable[[Any], bool] +PluginRuntimeStatusWriter = Callable[[str, PluginRuntimeStatus], None] class PluginLoader: @@ -34,11 +35,13 @@ class PluginLoader: import_preparer: PluginImportPreparer, import_scanner: PluginImportScanner, log: Any, + runtime_status_writer: Optional[PluginRuntimeStatusWriter] = None, ) -> None: - """保存插件目录、导入前置能力和日志端口。""" + """保存插件目录、导入前置能力、状态回写端口和日志端口。""" self._plugins_root = plugins_root self._import_preparer = import_preparer self._import_scanner = import_scanner + self._runtime_status_writer = runtime_status_writer self._logger = log def load( @@ -57,6 +60,12 @@ class PluginLoader: if plugin_id else [item.lower() for item in installed_plugins] ) + # 运行目录名统一小写,而卡片按调用方传入的原始 ID 读状态,回写前要还原大小写 + installed_ids = { + item.lower(): item + for item in ([plugin_id] if plugin_id else installed_plugins) + if item + } if not targets: self._logger.debug("没有需要加载的插件") return [] @@ -80,6 +89,9 @@ class PluginLoader: self._logger.warning( f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容" ) + self._mark_incompatible_runtime( + installed_ids.get(plugin_dir.name, plugin_dir.name) + ) continue try: @@ -127,6 +139,7 @@ class PluginLoader: self._logger.warning( f"跳过虚拟插件实例 {instance.instance_id}:声明与当前运行时不兼容" ) + self._mark_incompatible_runtime(instance.instance_id) return [] module_name = f"app.plugins.{instance.instance_id.lower()}" @@ -174,6 +187,29 @@ class PluginLoader: ) return [] + def is_runtime_compatible(self, plugin_id: str) -> bool: + """按插件 ID 判断运行目录载荷是否兼容当前运行时。 + + 公开可调用:生命周期在装载结果为空时要区分"加载失败"和"运行时不兼容", + 两者在卡片上是完全不同的提示。 + :param plugin_id: 物理插件 ID,大小写不敏感 + """ + return self._is_runtime_compatible(self._plugins_root / plugin_id.lower()) + + def _mark_incompatible_runtime(self, plugin_id: str) -> None: + """把运行时不兼容记成插件卡片可见的状态。 + + 按 ID 定向装载时生命周期的空结果分支也会落同一个状态;这里补的是全量装载 + (``plugin_id`` 为空)那条路径——它不逐个遍历目标,缺了这一笔,用户看到的 + 就是一张既不运行也不解释的占位卡片。 + """ + if not self._runtime_status_writer: + return + self._runtime_status_writer( + plugin_id, + PluginRuntimeStatus.INCOMPATIBLE_RUNTIME, + ) + @staticmethod def _is_runtime_compatible(plugin_dir: Path) -> bool: """按载荷自身 package 声明执行运行时兼容门禁,缺失声明时保持兼容。""" diff --git a/app/runtime/extensions/plugin/metadata.py b/app/runtime/extensions/plugin/metadata.py index 55cbb7b43..454ffb790 100644 --- a/app/runtime/extensions/plugin/metadata.py +++ b/app/runtime/extensions/plugin/metadata.py @@ -18,6 +18,7 @@ class PluginMetadataMapper: plugin_instance: Callable[[str], Optional[Any]], plugin_class: Callable[[str], Optional[Any]], annotate_system_version: Callable[[dict], dict], + annotate_runtime_compatibility: Callable[[dict[str, Any]], dict[str, Any]], is_package_compatible: Callable[[dict, str], bool], auth_checker: Callable[[Plugin, dict], bool], version_compare: Callable[[str, str, str], bool], @@ -27,6 +28,7 @@ class PluginMetadataMapper: self._plugin_instance = plugin_instance self._plugin_class = plugin_class self._annotate_system_version = annotate_system_version + self._annotate_runtime_compatibility = annotate_runtime_compatibility self._is_package_compatible = is_package_compatible self._auth_checker = auth_checker self._version_compare = version_compare @@ -44,7 +46,9 @@ class PluginMetadataMapper: """映射一个插件索引条目,不兼容或无权限时返回空。""" if not isinstance(plugin_info, dict): return None - info = self._annotate_system_version(plugin_info.copy()) + info = self._annotate_runtime_compatibility( + self._annotate_system_version(plugin_info.copy()) + ) if not self._is_package_compatible(info, package_version or ""): return None @@ -67,6 +71,10 @@ class PluginMetadataMapper: if info.get("system_version_compatible") is False: plugin.system_version_compatible = False plugin.system_version_message = info.get("system_version_message") + # 运行时不兼容不再把条目从目录里剔除:卡片照常显示,由前端据此禁用安装并说明原因 + if info.get("runtime_compatible") is False: + plugin.runtime_compatible = False + plugin.runtime_message = info.get("runtime_message") plugin.state = self._state(plugin_id, instance) plugin.has_page = bool( diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index dea6d15da..f1d1376f3 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -162,6 +162,9 @@ def build_plugin_runtime( plugins_root=environment.plugins_root, import_preparer=environment.import_preparer, import_scanner=environment.import_scanner, + # 运行时不兼容的插件不进入 lifecycle 遍历,状态只能由加载器自己落记, + # 否则启动期全量加载会把它们跳过得无声无息 + runtime_status_writer=registry.set_runtime_status, log=environment.logger, ) tools = PluginToolCatalog(max_attempts=tool_build_max_attempts) @@ -207,6 +210,12 @@ def build_plugin_runtime( plugins.extend(loader.load_instance(instance, validator)) return plugins + def runtime_compatible(plugin_id: str) -> bool: + """按分身归一到源插件目录后判断运行时兼容性。""" + instance = instances.get(plugin_id) + source_id = instance.source_plugin_id if instance else plugin_id + return loader.is_runtime_compatible(source_id) + lifecycle = PluginLifecycle( classes=registry.classes, running=registry.running, @@ -221,6 +230,7 @@ def build_plugin_runtime( enable_events=eventmanager.enable_event_handler, disable_events=eventmanager.disable_event_handler, runtime_status_writer=registry.set_runtime_status, + runtime_compatible=runtime_compatible, database=environment.database, log=environment.logger, event_sender=eventmanager.send_event, @@ -233,6 +243,9 @@ def build_plugin_runtime( annotate_system_version=lambda info: environment.system().annotate_system_version( info ), + annotate_runtime_compatibility=lambda info: ( + environment.system().annotate_runtime_compatibility(info) + ), is_package_compatible=lambda info, version: environment.system().is_package_compatible( info, version, diff --git a/app/runtime/extensions/plugin/sync.py b/app/runtime/extensions/plugin/sync.py index 3c589a7b6..5f9f0e8c1 100644 --- a/app/runtime/extensions/plugin/sync.py +++ b/app/runtime/extensions/plugin/sync.py @@ -97,7 +97,14 @@ class PluginSyncService: if getattr(plugin, "id", None) } ) - candidates = list(candidates_by_id.values()) + # 运行时声明不兼容的插件装不上:安装准入必然拒绝,同步失败状态会覆盖加载器写下的 + # 不兼容状态,恢复集合还会把整个启动同步判为未完成,连带跳过依赖恢复与调度器初始化。 + # 恢复分支同样排除——重装一个当前运行时装不上的插件不会有别的结果。 + candidates = [ + plugin + for plugin in candidates_by_id.values() + if plugin.runtime_compatible is not False + ] recovery_ids = { plugin.id.lower() for plugin in candidates diff --git a/app/runtime/extensions/plugin/system.py b/app/runtime/extensions/plugin/system.py index 4d47fa880..f7093493d 100644 --- a/app/runtime/extensions/plugin/system.py +++ b/app/runtime/extensions/plugin/system.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, cast class PluginSystemServices: @@ -59,6 +59,15 @@ class PluginSystemServices: """补充插件条目的主程序版本兼容信息。""" return self.market.annotate_system_version(plugin_info) + def annotate_runtime_compatibility( + self, plugin_info: dict[str, Any] + ) -> dict[str, Any]: + """补充插件条目的运行时(free-threaded)兼容信息。""" + return cast( + dict[str, Any], + self.market.annotate_runtime_compatibility(plugin_info), + ) + def is_package_compatible(self, plugin_info: dict, package_version: str) -> bool: """判断插件条目是否兼容指定代际。""" return self.market.is_package_compatible(plugin_info, package_version) diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 5b875dfbb..bd1c37111 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -22,6 +22,8 @@ class PluginRuntimeStatus(str, _Enum): ACTIVE = "active" BLOCKED_BY_POLICY = "blocked_by_policy" LOAD_FAILED = "load_failed" + # 插件自身声明与当前解释器运行时不兼容(当前只有 free-threaded 一个维度) + INCOMPATIBLE_RUNTIME = "incompatible_runtime" class PluginSourceBindingStatus(str, _Enum): @@ -204,6 +206,10 @@ class Plugin(BaseModel): system_version_message: Optional[str] = None # 主系统版本限定范围 system_version: Optional[str] = None + # 插件声明与当前解释器运行时是否兼容;free-threaded(v3t)下显式声明不支持时为 False + runtime_compatible: Optional[bool] = True + # 运行时不兼容的用户可读原因 + runtime_message: Optional[str] = None # 是否声明支持通过 GitHub Release 资产安装 release: Optional[bool] = False # 是否本地 diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 1dae05310..f41dc6b4e 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -588,7 +588,7 @@ def configure_plugin_services() -> None: inventory=load_inventory, identity=persistence.get_identity, candidate_compatibility=lambda candidate: ( - market_transport.check_plugin_system_version(candidate.dto) + market_transport.check_plugin_install_compatibility(candidate.dto) ), executor=command, clock=lambda: datetime.now(timezone.utc), diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index b3a92df83..0f1b68fbd 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -761,7 +761,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1044 | -| 内部导入边 | 8,867 | +| 内部导入边 | 8,865 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index d9ee0915c..213cf8317 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,867 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,865 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/docs/v3t-runtime-governance.md b/docs/v3t-runtime-governance.md index 9790c8037..da7d2f33c 100644 --- a/docs/v3t-runtime-governance.md +++ b/docs/v3t-runtime-governance.md @@ -113,6 +113,23 @@ Alembic revision 文件的旧程序;依赖自愈成功后再清理旧代际备 不得通过 `PYTHON_GIL=0` 或等效方式强制绕过扩展的 GIL 声明。扩展一旦使进程退化为 GIL 模式, 应保留可观测性并修复或替换依赖;无法及时处理时回退标准 V3。 +## 5.1 插件运行时声明与可见性 + +插件在 package 索引中用 `v3t: false` 声明不支持 free-threaded 运行时,缺省视为兼容,不要求生态 +全量标注。V3t 下该声明只改变可安装性和提示,不改变可见性: + +- 市场目录保留条目并标注 `runtime_compatible`、`runtime_message`,前端据此禁用安装并说明原因。 + 过滤掉条目会让插件在 V3t 上凭空消失,用户既看不到插件也看不到原因。 +- 安装准入统一经 `check_plugin_install_compatibility()` 判定,运行时判据先于宿主版本判据返回, + 在线安装与本地插件包共用这一条。前端禁用只是体验层,服务端必须独立拒绝。 +- 运行目录加载仍然跳过声明不兼容的载荷,并把插件状态写为 `incompatible_runtime`, + 由插件卡片显示不支持。按 ID 定向装载由生命周期的空结果分支落状态,全量装载不逐个遍历 + 目标,由加载器自己落记。 +- 启动同步把运行时不兼容的插件排除在候选之外。它们装不上,进入同步只会得到一次必然失败的 + 安装,并用同步失败状态覆盖不兼容状态;恢复集合还会据此把整个启动同步判为未完成。 + +该声明是人工标注,覆盖率天然滞后于真实依赖状况,不作为 V3t 依赖兼容性的唯一判据。 + ## 6. 当前 A/B 基线与决策 以下数据来自同一源码 revision、相同资源限制的 arm64 候选镜像,用于确定当前架构和依赖策略。后续 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index c8c168677..2d8d1209e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1084,8 +1084,8 @@ "runtime_only": true } }, - "edge_count": 8867, - "edge_sha256": "a109b2df878fb90a70b0cfd2a358eb291f460fe4a742c7d652820b7c203cd327", + "edge_count": 8865, + "edge_sha256": "6641acd38c2aec65ba3f527ce822ef687ed693bc8109c38b375bbd89499cdd68", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3796,8 +3796,6 @@ "app.application.plugin.inventory -> app.application.plugin.source", "app.application.plugin.inventory -> app.domain", "app.application.plugin.inventory -> app.domain.plugin", - "app.application.plugin.inventory -> app.foundation", - "app.application.plugin.inventory -> app.foundation.environment", "app.application.plugin.management -> app.application", "app.application.plugin.management -> app.application.commands", "app.application.plugin.management -> app.application.configuration", diff --git a/tests/test_plugin_candidate_inventory.py b/tests/test_plugin_candidate_inventory.py index 2c27a7759..0b54a2232 100644 --- a/tests/test_plugin_candidate_inventory.py +++ b/tests/test_plugin_candidate_inventory.py @@ -82,15 +82,12 @@ def test_only_v3_compatible_entries_are_candidates() -> None: assert not inventory.candidates_for("Undeclared") -def test_free_threaded_runtime_excludes_explicit_v3t_false( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """V3t 只拒绝明确声明不支持的插件,未声明仍保持兼容。""" - monkeypatch.setattr( - "app.application.plugin.inventory.is_free_threaded_runtime", - lambda: True, - ) +def test_free_threaded_runtime_keeps_explicit_v3t_false_candidates() -> None: + """V3t 下声明不支持的插件仍进入候选,由安装准入负责拒绝并说明原因。 + 市场过滤会让插件在 v3t 上凭空消失,用户既看不到插件也看不到原因; + 条目保留后前端才能在卡片上显示不支持。 + """ inventory = PluginCandidateInventoryReader( market_loader=lambda *_args: { "Allowed": {"version": "1.0.0"}, @@ -99,7 +96,8 @@ def test_free_threaded_runtime_excludes_explicit_v3t_false( ).load([THIRD_PARTY_MARKET]) assert {candidate.plugin_id for candidate in inventory.online_candidates} == { - "Allowed" + "Allowed", + "Rejected", } diff --git a/tests/test_plugin_catalog_service.py b/tests/test_plugin_catalog_service.py index 477cb3e34..c1f7b09f3 100644 --- a/tests/test_plugin_catalog_service.py +++ b/tests/test_plugin_catalog_service.py @@ -140,6 +140,7 @@ def test_metadata_mapper_preserves_package_generation_without_serializing_it(): plugin_instance=lambda _plugin_id: None, plugin_class=lambda _plugin_id: None, annotate_system_version=lambda info: info, + annotate_runtime_compatibility=lambda info: info, is_package_compatible=lambda _info, _version: True, auth_checker=lambda _plugin, _source: True, version_compare=lambda _left, _operator, _right: False, diff --git a/tests/test_plugin_database_lifecycle.py b/tests/test_plugin_database_lifecycle.py index b21cf6dbf..b871fa7be 100644 --- a/tests/test_plugin_database_lifecycle.py +++ b/tests/test_plugin_database_lifecycle.py @@ -105,6 +105,7 @@ def _build_lifecycle(**overrides: Any) -> PluginLifecycle: enable_events=lambda _plugin: None, disable_events=lambda _plugin: None, runtime_status_writer=lambda _plugin_id, _status: None, + runtime_compatible=lambda _plugin_id: True, database=lambda: PluginDatabase(), log=logging.getLogger(__name__), event_sender=lambda *_args, **_kwargs: None, diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 9f86b19da..f5e7a7b16 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -1084,7 +1084,10 @@ class TestPluginHelper: self, monkeypatch, ) -> None: - """V3t 只把 package 中明确的 v3t:false 视为运行时不兼容。""" + """V3t 只把 package 中明确的 v3t:false 视为运行时不兼容。 + + 代际判断保持通过,条目照常进入市场目录;不兼容只体现在运行时判据和标注上。 + """ from app.adapters.external.market import PluginHelper monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True) @@ -1096,9 +1099,50 @@ class TestPluginHelper: assert PluginHelper.is_package_plugin_compatible({}, "v3") assert PluginHelper.is_package_plugin_compatible({"v3t": True}, "v3") - assert not PluginHelper.is_package_plugin_compatible( - {"v3t": False}, "v3" + assert PluginHelper.is_package_plugin_compatible({"v3t": False}, "v3") + + assert PluginHelper.check_plugin_runtime_compatibility({"v3t": True})[0] + compatible, message = PluginHelper.check_plugin_runtime_compatibility( + {"v3t": False} ) + assert not compatible + assert "v3t" in message + + annotated = PluginHelper.annotate_plugin_runtime_compatibility( + {"v3t": False} + ) + assert annotated["runtime_compatible"] is False + assert annotated["runtime_message"] + + def test_install_compatibility_reports_runtime_before_system_version( + self, + monkeypatch, + ) -> None: + """安装准入在 v3t 上先报运行时不兼容,不把它说成版本问题。""" + from app.adapters.external.market import PluginHelper + + monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True) + + compatible, message = PluginHelper.check_plugin_install_compatibility( + {"v3t": False, "system_version": ">=99.0.0"} + ) + + assert not compatible + assert "free-threaded" in message + + def test_install_compatibility_falls_back_to_system_version( + self, + monkeypatch, + ) -> None: + """标准运行时下安装准入仍然只看主程序版本范围。""" + from app.adapters.external.market import PluginHelper + + monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: False) + + assert PluginHelper.check_plugin_install_compatibility({"v3t": False})[0] + assert not PluginHelper.check_plugin_install_compatibility( + {"system_version": ">=99.0.0"} + )[0] def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch): """ diff --git a/tests/test_plugin_helper_governance.py b/tests/test_plugin_helper_governance.py index e41468341..dcb1d4dd2 100644 --- a/tests/test_plugin_helper_governance.py +++ b/tests/test_plugin_helper_governance.py @@ -19,6 +19,7 @@ from app.adapters.system.plugin.package import ( from app.domain.plugin import ( build_local_plugin_source, build_plugin_release_install_plan, + check_plugin_runtime_compatibility, check_plugin_system_version, compatible_plugin_generations, is_plugin_generation_compatible, @@ -179,7 +180,8 @@ def test_generation_candidate_policy_is_stable_and_deduplicated() -> None: ({"v2": True}, None, True), ({"v3": False, "v2": True}, None, False), ({"v3": False}, "v3", False), - ({"v3t": False}, "v3", False), + # 运行时声明不参与代际判断:v3t 由 check_plugin_runtime_compatibility 单独判定 + ({"v3t": False}, "v3", True), ], ) def test_generation_policy_has_one_explicit_false_rule( @@ -187,15 +189,39 @@ def test_generation_policy_has_one_explicit_false_rule( generation: str | None, expected: bool, ) -> None: - """当前代际显式 false 和自由线程显式 false 都不得被专用索引绕过。""" + """当前代际显式 false 不得被专用索引绕过,运行时声明不改变代际结论。""" assert is_plugin_generation_compatible( metadata, generation, current_generation="v3", - free_threaded=True, ) is expected +@pytest.mark.parametrize( + ("metadata", "free_threaded", "compatible"), + [ + (object(), True, True), + ({}, True, True), + ({"v3t": True}, True, True), + ({"v3t": False}, True, False), + ({"v3t": False}, False, True), + ], +) +def test_runtime_policy_only_rejects_explicit_free_threaded_refusal( + metadata: object, + free_threaded: bool, + compatible: bool, +) -> None: + """运行时判据只在 free-threaded 且显式声明不支持时拒绝。""" + result, message = check_plugin_runtime_compatibility( + metadata, + free_threaded=free_threaded, + ) + + assert result is compatible + assert bool(message) is not compatible + + @pytest.mark.parametrize( ("metadata", "generation", "current", "expected"), [ @@ -219,7 +245,6 @@ def test_generation_policy_covers_all_owner_decisions( metadata, generation, current_generation=current, - free_threaded=False, ) is expected diff --git a/tests/test_plugin_instance_log_context_entrypoints.py b/tests/test_plugin_instance_log_context_entrypoints.py index ce8c66f22..57ffd8857 100644 --- a/tests/test_plugin_instance_log_context_entrypoints.py +++ b/tests/test_plugin_instance_log_context_entrypoints.py @@ -46,6 +46,7 @@ def _lifecycle(*, plugins, running=None): enable_events=MagicMock(), disable_events=MagicMock(), runtime_status_writer=MagicMock(), + runtime_compatible=lambda _pid: True, database=lambda: PluginDatabase(), log=MagicMock(), event_sender=MagicMock(), diff --git a/tests/test_plugin_lifecycle_concurrency.py b/tests/test_plugin_lifecycle_concurrency.py index 1ae618126..354b6080b 100644 --- a/tests/test_plugin_lifecycle_concurrency.py +++ b/tests/test_plugin_lifecycle_concurrency.py @@ -70,6 +70,7 @@ def _lifecycle(*, plugin_class: type, running: dict) -> PluginLifecycle: enable_events=MagicMock(), disable_events=MagicMock(), runtime_status_writer=lambda _plugin_id, _status: None, + runtime_compatible=lambda _plugin_id: True, database=lambda: MagicMock(), log=MagicMock(), event_sender=MagicMock(), diff --git a/tests/test_plugin_lifecycle_status.py b/tests/test_plugin_lifecycle_status.py index fb9823706..c8fae74bd 100644 --- a/tests/test_plugin_lifecycle_status.py +++ b/tests/test_plugin_lifecycle_status.py @@ -32,6 +32,7 @@ def _lifecycle( remove_classification=None, enable_events=None, log=None, + runtime_compatible=True, ): """构造隔离外部事件和模块清理的生命周期实例。""" classes = {} @@ -49,6 +50,7 @@ def _lifecycle( enable_events=enable_events or MagicMock(), disable_events=MagicMock(), runtime_status_writer=statuses.__setitem__, + runtime_compatible=lambda _plugin_id: runtime_compatible, database=lambda: PluginDatabase(), log=log or MagicMock(), event_sender=MagicMock(), @@ -125,6 +127,20 @@ def test_lifecycle_records_load_failure_when_loader_returns_no_class(): assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED +def test_lifecycle_reports_incompatible_runtime_instead_of_load_failure(): + """载荷声明与当前运行时不兼容时,卡片要显示不支持而不是加载失败。""" + lifecycle, _classes, running, statuses = _lifecycle( + plugins=[], + runtime_compatible=False, + ) + + result = lifecycle.start("DemoPlugin") + + assert result == {"DemoPlugin": PluginRuntimeStatus.INCOMPATIBLE_RUNTIME} + assert running == {} + assert statuses["DemoPlugin"] is PluginRuntimeStatus.INCOMPATIBLE_RUNTIME + + def test_classification_registration_precedes_event_enablement(): """启用插件必须先刷新分类声明,再开放可能产生扩展事实的事件入口。""" order: list[str] = [] diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index 5637309b4..3003a5882 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -257,6 +257,41 @@ def test_local_plugin_candidate_keeps_system_version_gate_outside_dev( assert "MoviePilot 版本 >=2.13.11" in candidate["skip_reason"] +def test_local_plugin_candidate_marks_runtime_incompatible_as_unusable( + tmp_path, + monkeypatch, + plugin_manager: PluginManager, +) -> None: + """free-threaded 运行时下声明不支持的本地候选必须投影为不可安装。 + + 本地热同步只认 compatible 位;漏掉运行时这一位,每次源码变更都会发起一次 + 必然被安装准入拒绝的安装,只留下一条无意义的失败日志。 + """ + repo_path, source_file = _build_local_plugin_repo(tmp_path) + (repo_path / "package.v2.json").write_text( + '{"DemoPlugin": {"version": "1.0.0", "v3t": false}}', + encoding="utf-8", + ) + + _patch_plugin_runtime_settings( + monkeypatch, + SimpleNamespace( + DEV=False, + ROOT_PATH=tmp_path, + PLUGIN_LOCAL_REPO_PATHS=str(repo_path), + ), + ) + from app.adapters.external.plugin import client as plugin_client_module + + monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True) + + candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file) + + assert candidate["runtime_compatible"] is False + assert candidate["compatible"] is False + assert "v3t" in candidate["skip_reason"] + + def test_local_plugin_sync_without_candidate_respects_system_version_gate( tmp_path, monkeypatch, diff --git a/tests/test_plugin_sync_service.py b/tests/test_plugin_sync_service.py index eafdbf0ed..14fab2753 100644 --- a/tests/test_plugin_sync_service.py +++ b/tests/test_plugin_sync_service.py @@ -59,6 +59,7 @@ def test_market_sync_keeps_install_rollback_enabled() -> None: plugin_name="Demo", plugin_version="1.0.0", system_version_compatible=True, + runtime_compatible=True, ) install = Mock(return_value=(True, "")) service = PluginSyncService( @@ -84,6 +85,7 @@ def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None: plugin_name="Demo old", plugin_version="1.0.0", system_version_compatible=True, + runtime_compatible=True, ) new = SimpleNamespace( id="DemoPlugin", @@ -91,6 +93,7 @@ def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None: plugin_name="Demo new", plugin_version="2.0.0", system_version_compatible=True, + runtime_compatible=True, ) install = Mock(return_value=(True, "")) @@ -120,6 +123,7 @@ def test_market_sync_restores_trusted_online_payload_after_local_source_removed( plugin_name="Demo", plugin_version="1.2.0", system_version_compatible=False, + runtime_compatible=True, ) install = Mock(return_value=(True, "")) service = PluginSyncService( @@ -139,6 +143,68 @@ def test_market_sync_restores_trusted_online_payload_after_local_source_removed( install.assert_called_once_with(plugin.id, None, False, None) +def test_market_sync_excludes_runtime_incompatible_candidates() -> None: + """运行时声明不兼容的插件不得进入启动同步。 + + 这类插件装不上:安装准入必然拒绝,同步失败状态会覆盖加载器写下的不兼容状态, + 恢复集合还会把整个启动同步判为未完成,连带跳过依赖恢复与调度器初始化。 + """ + plugin = SimpleNamespace( + id="DemoPlugin", + repo_url=REPO_URL, + plugin_name="Demo", + plugin_version="1.0.0", + system_version_compatible=True, + runtime_compatible=False, + ) + install = Mock(return_value=(False, "插件声明不支持 free-threaded 运行时(v3t)")) + status_writer = Mock() + service = PluginSyncService( + frozen=lambda: False, + installed_plugins=lambda: [plugin.id], + online_plugins=lambda: [plugin], + local_plugins=lambda: [], + merge_plugins=lambda items, *_args: items, + # 运行目录缺失是这类插件在 v3t 上的常态:加载器跳过后它不在运行表里 + plugin_exists=lambda *_args: False, + install=install, + log=Mock(), + runtime_status_writer=status_writer, + ) + + assert service.sync() == [] + install.assert_not_called() + # 不能用同步失败状态覆盖加载器写下的 INCOMPATIBLE_RUNTIME + status_writer.assert_not_called() + + +def test_market_sync_excludes_runtime_incompatible_restore_targets() -> None: + """恢复分支同样排除:重装当前运行时装不上的插件不会有别的结果。""" + plugin = SimpleNamespace( + id="DemoPlugin", + repo_url=REPO_URL, + plugin_name="Demo", + plugin_version="1.0.0", + system_version_compatible=True, + runtime_compatible=False, + ) + install = Mock(return_value=(False, "插件声明不支持 free-threaded 运行时(v3t)")) + service = PluginSyncService( + frozen=lambda: False, + installed_plugins=lambda: [plugin.id], + online_plugins=lambda: [plugin], + local_plugins=lambda: [], + merge_plugins=lambda items, *_args: items, + plugin_exists=lambda *_args: True, + install=install, + log=Mock(), + ) + + # 恢复失败会抛 RuntimeError 中断启动同步,这里必须连候选都不产生 + assert service.sync(online_restore_plugins={"demoplugin"}) == [] + install.assert_not_called() + + def test_market_sync_skips_existing_local_candidate() -> None: """运行目录已有可用插件时,启动同步不因本地候选重复安装。""" online = SimpleNamespace( @@ -147,6 +213,7 @@ def test_market_sync_skips_existing_local_candidate() -> None: plugin_name="Demo", plugin_version="1.2.0", system_version_compatible=True, + runtime_compatible=True, ) local = SimpleNamespace( id="DemoPlugin", @@ -154,6 +221,7 @@ def test_market_sync_skips_existing_local_candidate() -> None: plugin_name="Demo Local", plugin_version="9.9.10", system_version_compatible=True, + runtime_compatible=True, ) install = Mock(return_value=(True, "")) service = PluginSyncService( @@ -179,6 +247,7 @@ def test_market_sync_defers_source_selection_to_gateway() -> None: plugin_name="Demo Local", plugin_version="3.3.2", system_version_compatible=True, + runtime_compatible=True, ) install = Mock(return_value=(True, "")) service = PluginSyncService( @@ -204,6 +273,7 @@ def test_market_sync_reports_local_install_failure() -> None: plugin_name="Demo Local", plugin_version="3.3.2", system_version_compatible=True, + runtime_compatible=True, ) service = PluginSyncService( frozen=lambda: False, @@ -359,6 +429,7 @@ async def test_market_sync_preserves_generation_priority_through_gateway( plugin_name="Demo Local", plugin_version=local.plugin_version, system_version_compatible=True, + runtime_compatible=True, ) merged_online = SimpleNamespace( id=online.plugin_id, @@ -366,6 +437,7 @@ async def test_market_sync_preserves_generation_priority_through_gateway( plugin_name="Download Center", plugin_version=online.plugin_version, system_version_compatible=True, + runtime_compatible=True, ) service = PluginSyncService( frozen=lambda: False, @@ -497,6 +569,7 @@ async def test_market_sync_blocks_activation_when_gateway_selected_local_fails( plugin_name="Demo Official", plugin_version=official.plugin_version, system_version_compatible=True, + runtime_compatible=True, ) merged_competing = SimpleNamespace( id=competing.plugin_id, @@ -504,6 +577,7 @@ async def test_market_sync_blocks_activation_when_gateway_selected_local_fails( plugin_name="Demo Competing", plugin_version=competing.plugin_version, system_version_compatible=True, + runtime_compatible=True, ) merged_local = SimpleNamespace( id=local.plugin_id, @@ -511,6 +585,7 @@ async def test_market_sync_blocks_activation_when_gateway_selected_local_fails( plugin_name="Demo Local", plugin_version=local.plugin_version, system_version_compatible=True, + runtime_compatible=True, ) service = PluginSyncService( frozen=lambda: False, @@ -548,6 +623,7 @@ async def test_market_sync_reuses_startup_lease_through_real_gateway( plugin_name="Demo", plugin_version="9.0.0", system_version_compatible=True, + runtime_compatible=True, ) official_candidate = PluginMarketCandidate( plugin_id=plugin.id, diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index e3df9c7f2..f3910ed12 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -648,6 +648,46 @@ def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations( assert not PluginLoader._is_runtime_compatible(rejected) +def test_loader_marks_incompatible_runtime_status_when_skipping( + tmp_path, + monkeypatch, +): + """运行时不兼容的插件被跳过时必须留下卡片可见的状态。 + + 启动期全量加载不会让生命周期遍历到这些插件,状态只能由加载器落记; + 否则用户在 v3t 上只能看到一张既不运行也不解释的占位卡片。 + """ + from app.runtime.extensions.plugin import loader as loader_module + + monkeypatch.setattr( + loader_module, + "get_runtime_setting", + lambda key: "v3" if key == "VERSION_FLAG" else None, + ) + monkeypatch.setattr(loader_module, "is_free_threaded_runtime", lambda: True) + + plugin_dir = tmp_path / "rejectedplugin" + plugin_dir.mkdir() + (plugin_dir / "__init__.py").write_text("", encoding="utf-8") + (plugin_dir / "package.json").write_text('{"v3t": false}', encoding="utf-8") + + statuses: dict[str, PluginRuntimeStatus] = {} + loader = PluginLoader( + plugins_root=tmp_path, + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + runtime_status_writer=statuses.__setitem__, + log=_logger(), + ) + + assert loader.load(None, ["RejectedPlugin"], lambda _candidate: True) == [] + # 安装清单里的原始大小写必须保留:卡片按该 ID 读状态 + assert statuses == { + "RejectedPlugin": PluginRuntimeStatus.INCOMPATIBLE_RUNTIME + } + assert not loader.is_runtime_compatible("RejectedPlugin") + + def test_clone_service_persists_descriptor_without_copying_source_package(): """创建分身只写实例描述和隔离配置,并始终跟随源插件版本。""" instances: dict[str, PluginInstance] = {}