diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 59d627f8b..987d3cf1d 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -47,7 +47,46 @@ _installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins _plugin_install_gateway: PluginInstallGateway = _unconfigured_plugin_install_gateway _async_plugin_install_gateway: AsyncPluginInstallGateway = _unconfigured_async_plugin_install_gateway _plugin_market_transport = PluginMarketTransport() -_plugin_runtime_health = PluginRuntimeHealth() +_plugin_runtime_health: Optional[PluginRuntimeHealth] = None +_plugin_dependency_installer: Optional[PluginDependencyInstaller] = None + + +def configure_plugin_runtime_owners( + *, + health: PluginRuntimeHealth, + dependency: PluginDependencyInstaller, +) -> None: + """发布组合根构造的插件运行健康与依赖安装唯一 owner。""" + global _plugin_runtime_health, _plugin_dependency_installer + _plugin_runtime_health = health + _plugin_dependency_installer = dependency + + +def reset_plugin_runtime_owners() -> None: + """撤销当前 lifespan 发布的插件运行健康与依赖安装 owner。""" + global _plugin_runtime_health, _plugin_dependency_installer + _plugin_runtime_health = None + _plugin_dependency_installer = None + + +def _plugin_runtime_health_owner() -> PluginRuntimeHealth: + """返回已发布 owner;旧入口提前调用时惰性保留兼容行为。""" + global _plugin_runtime_health + if _plugin_runtime_health is None: + _plugin_runtime_health = PluginRuntimeHealth() + return _plugin_runtime_health + + +def _plugin_dependency_owner() -> PluginDependencyInstaller: + """返回已发布 owner;旧入口提前调用时只构造一份惰性实例。""" + global _plugin_dependency_installer + if _plugin_dependency_installer is None: + _plugin_dependency_installer = PluginDependencyInstaller( + _plugin_runtime_health_owner(), + installed_plugins_provider=lambda: _installed_plugins_provider(), + plugin_dir=PLUGIN_DIR, + ) + return _plugin_dependency_installer def configure_installed_plugins_provider(provider: InstalledPluginsProvider) -> None: @@ -144,23 +183,17 @@ class PluginHelper(metaclass=WeakSingleton): find_links_dirs: Optional[List[Path]] = None, ) -> Tuple[bool, str]: """把兼容入口委托给系统运行环境 owner。""" - return _plugin_runtime_health.install_packages_with_fallback( + return _plugin_runtime_health_owner().install_packages_with_fallback( dependency_files, find_links_dirs ) def find_missing_dependencies(self) -> List[str]: """把兼容入口委托给依赖聚合 owner。""" - return PluginDependencyInstaller( - _plugin_runtime_health, installed_plugins_provider=_installed_plugins_provider, - plugin_dir=PLUGIN_DIR, - ).find_missing() + return _plugin_dependency_owner().find_missing() def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]: """把兼容入口委托给依赖聚合 owner。""" - return PluginDependencyInstaller( - _plugin_runtime_health, installed_plugins_provider=_installed_plugins_provider, - plugin_dir=PLUGIN_DIR, - ).install(dependencies) + return _plugin_dependency_owner().install(dependencies) @classmethod async def async_install_packages_with_fallback( @@ -168,7 +201,7 @@ class PluginHelper(metaclass=WeakSingleton): find_links_dirs: Optional[List[Path]] = None, ) -> Tuple[bool, str]: """把异步兼容入口委托给系统运行环境 owner。""" - return await _plugin_runtime_health.async_install_packages_with_fallback( + return await _plugin_runtime_health_owner().async_install_packages_with_fallback( dependency_files, find_links_dirs ) @@ -176,17 +209,11 @@ class PluginHelper(metaclass=WeakSingleton): self, dependencies: List[str], ) -> Tuple[bool, str]: """把异步兼容入口委托给依赖聚合 owner。""" - return await PluginDependencyInstaller( - _plugin_runtime_health, installed_plugins_provider=_installed_plugins_provider, - plugin_dir=PLUGIN_DIR, - ).async_install(dependencies) + return await _plugin_dependency_owner().async_install(dependencies) async def async_find_missing_dependencies(self) -> List[str]: """把异步兼容入口委托给依赖聚合 owner。""" - return await PluginDependencyInstaller( - _plugin_runtime_health, installed_plugins_provider=_installed_plugins_provider, - plugin_dir=PLUGIN_DIR, - ).async_find_missing() + return await _plugin_dependency_owner().async_find_missing() async def async_install( self, pid: str, repo_url: str, package_version: Optional[str] = None, diff --git a/app/startup/composition/plugin.py b/app/startup/composition/plugin.py index 54971db17..a34f4d11a 100644 --- a/app/startup/composition/plugin.py +++ b/app/startup/composition/plugin.py @@ -24,6 +24,7 @@ class PluginMarketComposition: transport: PluginMarketTransport client: PluginMarketClient package: PluginPackageManager + health: PluginRuntimeHealth dependency: PluginDependencyInstaller @@ -40,6 +41,7 @@ def compose_plugin_market( plugin_root = root_path / "app" / "plugins" transport = PluginMarketTransport.get_existing_instance() or PluginMarketTransport() client = PluginMarketClient(transport) + health = PluginRuntimeHealth() composition = PluginMarketComposition( transport=transport, client=client, @@ -48,10 +50,11 @@ def compose_plugin_market( plugin_root=plugin_root, ), dependency=PluginDependencyInstaller( - PluginRuntimeHealth(), + health, installed_plugins_provider=installed_plugins_provider, plugin_dir=plugin_root, ), + health=health, ) _market_client = client return composition diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index b894df72b..684161a2b 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -9,6 +9,8 @@ from app.adapters.external.market import ( LOCAL_REPO_PREFIX, configure_installed_plugins_provider, configure_plugin_install_gateway, + configure_plugin_runtime_owners, + reset_plugin_runtime_owners, ) from app.adapters.external.plugin.client import ( VERSION_BACKWARD_COMPATIBLE_FLAGS, @@ -190,6 +192,10 @@ def configure_plugin_services() -> None: market_transport = market_composition.transport market_client = market_composition.client package_manager = market_composition.package + configure_plugin_runtime_owners( + health=market_composition.health, + dependency=market_composition.dependency, + ) plugin_manager = get_plugin_manager() inventory_reader = PluginCandidateInventoryReader( market_loader=market_client.get_plugin_index_result, @@ -775,4 +781,5 @@ def stop_plugins() -> bool: reset_plugin_catalog_query() reset_plugin_release_service() reset_plugin_rating_service() + reset_plugin_runtime_owners() reset_plugin_market_composition() diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 8b3721925..12d38ae3a 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -227,13 +227,17 @@ re-exported from the package root, SDK or Compat. Complexity and concurrency governance covers the complete canonical execution surface rather than only public methods. `scripts/architecture/complexity.py --v2` -ratchets private and nested methods, class/file hotspots, and the -`app/scheduler/` package; its generated baseline is an evidence ledger, not a -permission to add another oversized owner. `scripts/architecture/concurrency.py` -enumerates native Thread, Timer, thread/process executors, Process, -asyncio task/thread helpers, and executor hand-off calls across host roots. Each -fact must have a stable lexical owner and remain in the baseline with no new -call site or owner drift. `app/plugins/**`, `app/sdk/**`, and +uses complete AST child traversal to ratchet private/dunder/nested methods, +class/file hotspots, and the `app/scheduler/` package, including owners nested +under `Match`, `TryStar`, and other control-flow nodes. Its generated baseline +is an evidence ledger, not permission to add another oversized owner. +`scripts/architecture/concurrency.py` resolves canonical imports and aliases for +native Thread, Timer, thread/process executors, Process, TaskGroup, event-loop +task submission, asyncio task/thread helpers, and executor hand-off calls. It +scans the complete `app/**` host tree and aggregates each canonical target by +stable lexical owner and call count, so physical line movement is not semantic +drift and multiple same-line calls cannot collapse. New owners/count growth and +stale baselines after count reduction all block CI. `app/plugins/**`, `app/sdk/**`, and `app/runtime/compat/**` are excluded because their compatibility/runtime contracts are governed by their own exact manifests. @@ -1030,6 +1034,7 @@ driven workflow registration. | `app/adapters/system/plugin/package.py` | `PluginPackageManager`, the unique install, checkpoint, backup, restore and physical-delete owner | | `app/adapters/system/plugin/dependency.py` | `PluginDependencyInstaller`, the dependency manifest, missing-package inspection and installation owner | | `app/adapters/system/plugin/health.py` | `PluginRuntimeHealth`, the runtime-environment guard, pre/post-install check and recovery owner | +| `app/startup/composition/plugin.py` | Constructs one market transport/client, package manager, runtime-health owner and dependency installer per lifespan; `PluginHelper` compatibility calls consume those same published owners | | `app/runtime/extensions/resource.py` | Data-only managed-resource registry and sync/async lifecycle adapters | | `app/runtime/resources.py` | Lightweight acquisition, state observation and shutdown facade | | `app/foundation/reflection.py` | Generic reflection and Python module discovery | diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index e34ab3bdc..a2cc08ffb 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -2392,6 +2392,7 @@ def test_plugin_market_construction_is_owned_by_startup_composition(): "PluginMarketTransport", "PluginPackageSourceClient", "PluginPackageManager", + "PluginRuntimeHealth", "PluginDependencyInstaller", } assert forbidden_names.isdisjoint(initializer_imports) @@ -2417,6 +2418,7 @@ def test_plugin_market_construction_is_owned_by_startup_composition(): assert composition_calls.count("PluginMarketTransport") == 1 assert composition_calls.count("PluginPackageSourceClient") == 1 assert composition_calls.count("PluginPackageManager") == 1 + assert composition_calls.count("PluginRuntimeHealth") == 1 assert composition_calls.count("PluginDependencyInstaller") == 1 @@ -2428,6 +2430,28 @@ def test_plugin_market_transport_is_process_shared_across_compat_and_composition assert PluginMarketTransport.get_existing_instance() is _plugin_market_transport +def test_plugin_runtime_owners_are_shared_with_compat_facade(): + """兼容门面必须消费组合根发布的健康检查与依赖安装 owner。""" + from app.adapters.external.market import ( + _plugin_dependency_owner, + _plugin_runtime_health_owner, + configure_plugin_runtime_owners, + reset_plugin_runtime_owners, + ) + + health = object() + dependency = object() + configure_plugin_runtime_owners( + health=health, # type: ignore[arg-type] + dependency=dependency, # type: ignore[arg-type] + ) + try: + assert _plugin_runtime_health_owner() is health + assert _plugin_dependency_owner() is dependency + finally: + reset_plugin_runtime_owners() + + def test_domain_initializer_delegates_construction_to_composition(): """领域 initializer 只负责生命周期调用,不得直接构造 Adapter 或领域服务。""" initializer_path = APP_ROOT / "startup" / "initializers" / "domain.py" diff --git a/tests/test_plugin_external_install_boundary.py b/tests/test_plugin_external_install_boundary.py index c4c384bd1..d7df3fed3 100644 --- a/tests/test_plugin_external_install_boundary.py +++ b/tests/test_plugin_external_install_boundary.py @@ -712,36 +712,25 @@ async def test_agent_install_uses_application_gateway( def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> None: """启动组合根必须装配独立 owner,并向公开 Helper 发布同一 Gateway。""" transport = Mock() - source = Mock() + market_client = Mock() package_manager = Mock() + health = Mock() + dependency = Mock() gateway_calls = [] application_calls = [] - transport_consumers = [] gateway = Mock() sync_runner = Mock(return_value=(True, "installed")) async_runner = AsyncMock(return_value=(True, "installed")) monkeypatch.setattr( plugins_initializer, - "PluginMarketTransport", - lambda: transport, - ) - monkeypatch.setattr( - plugins_initializer, - "PluginMarketClient", - lambda injected: transport_consumers.append(("market", injected)) or Mock(), - ) - monkeypatch.setattr( - plugins_initializer, - "PluginPackageSourceClient", - lambda injected: transport_consumers.append(("package", injected)) or source, - ) - monkeypatch.setattr( - plugins_initializer, - "PluginPackageManager", - lambda **kwargs: ( - transport_consumers.append(("package-owner", kwargs["source"])) - or package_manager + "compose_plugin_market", + lambda **_kwargs: SimpleNamespace( + transport=transport, + client=market_client, + package=package_manager, + health=health, + dependency=dependency, ), ) monkeypatch.setattr( @@ -796,11 +785,6 @@ def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> "_run_plugin_install_async", async_runner, ) - monkeypatch.setattr( - plugins_initializer, - "PluginDependencyInstaller", - lambda *_args, **_kwargs: Mock(), - ) for name in ( "configure_plugin_legacy_import_services", "configure_plugin_resource_import_preparer", @@ -809,6 +793,7 @@ def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> "configure_plugin_catalog_factory", "configure_plugin_runtime_factory", "configure_plugin_route_refresher", + "configure_plugin_runtime_owners", "configure_plugin_system", "configure_plugin_storage", ): @@ -827,11 +812,6 @@ def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> plugins_initializer.configure_plugin_services() - assert transport_consumers == [ - ("market", transport), - ("package", transport), - ("package-owner", source), - ] assert application_calls == [gateway] assert len(gateway_calls) == 1 assert callable(gateway_calls[0]["install"])