fix(plugin): v3t 下不再隐藏不兼容插件,改为提示不支持 (#6760)

* feat(plugin): surface v3t incompatibility instead of hiding plugins

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(architecture): refresh dependency baseline after plugin gate split

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plugin): keep runtime-incompatible plugins out of startup sync

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plugin): annotate runtime compatibility ports without bare dict

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
InfinityPacer
2026-09-23 00:35:20 +08:00
committed by GitHub
parent 3b0830fba0
commit 0238351071
31 changed files with 514 additions and 52 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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(

View File

@@ -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")

View File

@@ -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:

View File

@@ -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
),
}
),
)

View File

@@ -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,
*,

View File

@@ -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()

View File

@@ -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 声明执行运行时兼容门禁,缺失声明时保持兼容。"""

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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
# 是否本地

View File

@@ -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),

View File

@@ -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) |

View File

@@ -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 等基线均为零 |

View File

@@ -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 候选镜像,用于确定当前架构和依赖策略。后续

View File

@@ -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",

View File

@@ -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",
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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] = []

View File

@@ -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,

View File

@@ -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,

View File

@@ -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] = {}