from __future__ import annotations import asyncio import sys import threading import types from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path from typing import Any, Optional from unittest.mock import AsyncMock, patch import pytest from app.runtime.capabilities.errors import ( CapabilityAdapterModeError, CapabilityOperationError, CapabilityRuntimeClosedError, ) from app.runtime.capabilities.model import ( AdapterExecutionMode, CapabilityLifecycleState, CapabilityMaterializationState, ) from app.runtime.capabilities.registry import CapabilityRegistry from app.runtime.capabilities.runtime import CapabilityRuntime _MANIFEST = """ schema_version = 1 id = "sample.capability" kind = "sample" entrypoint = "sample_implementation:SampleCapability" depends_on = [] [metadata] name = "Sample capability" [activation] policy = "on_first_use" watch = [] """ def _registry(tmp_path: Path) -> CapabilityRegistry: manifest_dir = tmp_path / "sample" manifest_dir.mkdir(parents=True) (manifest_dir / "capability.toml").write_text( _MANIFEST.strip() + "\n", encoding="utf-8", ) return CapabilityRegistry.discover( roots=[tmp_path], kinds={"sample"}, selector_schemas={}, ) @dataclass class _Candidate: generation: int started: bool = False stopped: bool = False class _SampleCapability: pass @pytest.fixture(autouse=True) def isolate_sample_entrypoints() -> Iterator[None]: """隔离合成 adapter 在 sys.modules 中公开的 canonical entrypoint。""" module_names = ("sample_implementation", "other_implementation") previous = {name: sys.modules[name] for name in module_names if name in sys.modules} for name in module_names: sys.modules.pop(name, None) yield for name in module_names: sys.modules.pop(name, None) if name in previous: sys.modules[name] = previous[name] def _materialize_sample(spec) -> type[_SampleCapability]: module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1) module = types.ModuleType(module_name) setattr(module, symbol_name, _SampleCapability) sys.modules[module_name] = module return _SampleCapability class _SyncAdapter: execution_mode = AdapterExecutionMode.SYNC def __init__(self) -> None: self.materialize_calls = 0 self.create_calls = 0 self.start_calls = 0 self.stop_calls = 0 self.stop_instances = [] self.cleanup_calls = 0 self.fail_materialize = False self.fail_start = False self.fail_stop = False self.start_entered: Optional[threading.Event] = None self.start_release: Optional[threading.Event] = None self.stop_entered: Optional[threading.Event] = None self.stop_release: Optional[threading.Event] = None def materialize(self, spec) -> object: self.materialize_calls += 1 if self.fail_materialize: raise RuntimeError("materialize failed") return _materialize_sample(spec) def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate: self.create_calls += 1 return _Candidate(generation=generation) def start(self, spec, candidate: _Candidate, generation: int) -> None: self.start_calls += 1 if self.start_entered: self.start_entered.set() if self.start_release: assert self.start_release.wait(timeout=5) candidate.started = True if self.fail_start: raise RuntimeError("start failed") def stop(self, spec, instance: _Candidate, generation: int) -> None: self.stop_calls += 1 self.stop_instances.append(instance) if self.stop_entered: self.stop_entered.set() if self.stop_release: assert self.stop_release.wait(timeout=5) instance.stopped = True if self.fail_stop: raise RuntimeError("stop failed") def cleanup(self, spec, candidate: _Candidate, generation: int, error: BaseException) -> None: self.cleanup_calls += 1 candidate.stopped = True class _AsyncAdapter: execution_mode = AdapterExecutionMode.ASYNC def __init__(self) -> None: self.materialize_calls = 0 self.create_calls = 0 self.start_calls = 0 self.stop_calls = 0 self.stop_instances = [] self.cleanup_calls = 0 self.fail_materialize = False self.fail_start = False self.fail_stop = False self.start_entered = asyncio.Event() self.start_release = asyncio.Event() self.stop_entered: Optional[asyncio.Event] = None self.stop_release: Optional[asyncio.Event] = None async def materialize(self, spec) -> object: self.materialize_calls += 1 await asyncio.sleep(0) if self.fail_materialize: raise RuntimeError("async materialize failed") return _materialize_sample(spec) async def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate: self.create_calls += 1 await asyncio.sleep(0) return _Candidate(generation=generation) async def start(self, spec, candidate: _Candidate, generation: int) -> None: self.start_calls += 1 self.start_entered.set() await self.start_release.wait() candidate.started = True if self.fail_start: raise RuntimeError("async start failed") async def stop(self, spec, instance: _Candidate, generation: int) -> None: self.stop_calls += 1 self.stop_instances.append(instance) if self.stop_entered: self.stop_entered.set() if self.stop_release: await self.stop_release.wait() await asyncio.sleep(0) instance.stopped = True if self.fail_stop: raise RuntimeError("async stop failed") async def cleanup(self, spec, candidate: _Candidate, generation: int, error: BaseException) -> None: self.cleanup_calls += 1 await asyncio.sleep(0) candidate.stopped = True def test_materialize_and_start_have_independent_state_axes(tmp_path: Path) -> None: """兼容查询只物化代码,资源必须等显式 activate 成功后才对外可见。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) implementation = runtime.materialize("sample.capability", reason="compat_lookup") materialized = runtime.snapshot("sample.capability") assert implementation is not None assert materialized.materialization is CapabilityMaterializationState.RESOLVED assert materialized.lifecycle is CapabilityLifecycleState.DISCOVERED assert materialized.visible is False assert adapter.start_calls == 0 instance = runtime.activate("sample.capability", reason="first_use") running = runtime.snapshot("sample.capability") assert runtime.get_running("sample.capability") is instance assert running.lifecycle is CapabilityLifecycleState.RUNNING assert running.visible is True assert running.generation == 2 def test_materialize_failure_does_not_claim_resource_lifecycle_failure(tmp_path: Path) -> None: """仅解析代码失败时,资源轴尚未启动,必须保持 DISCOVERED。""" adapter = _SyncAdapter() adapter.fail_materialize = True runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) with pytest.raises(CapabilityOperationError, match="materialize failed"): runtime.materialize("sample.capability", reason="compat_lookup") snapshot = runtime.snapshot("sample.capability") assert snapshot.materialization is CapabilityMaterializationState.FAILED assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED assert snapshot.visible is False def test_materialize_rejects_missing_canonical_module( tmp_path: Path, ) -> None: """adapter 未加载 manifest 指定模块时不得发布替代实现。""" adapter = _SyncAdapter() runtime = CapabilityRuntime( _registry(tmp_path), adapters={"sample": adapter}, ) with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises( CapabilityOperationError, match="canonical 模块.*未加载", ): runtime.materialize("sample.capability", reason="invalid_adapter") snapshot = runtime.snapshot("sample.capability") assert snapshot.materialization is CapabilityMaterializationState.FAILED assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED def test_materialize_rejects_missing_canonical_symbol( tmp_path: Path, ) -> None: """adapter 未公开 manifest 指定符号时不得发布替代实现。""" sys.modules["sample_implementation"] = types.ModuleType("sample_implementation") adapter = _SyncAdapter() runtime = CapabilityRuntime( _registry(tmp_path), adapters={"sample": adapter}, ) with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises( CapabilityOperationError, match="canonical 符号.*不存在", ): runtime.materialize("sample.capability", reason="invalid_adapter") snapshot = runtime.snapshot("sample.capability") assert snapshot.materialization is CapabilityMaterializationState.FAILED assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED def test_activate_revalidates_missing_canonical_module_on_retry(tmp_path: Path) -> None: """同步激活的物化合同失败不得被记为已解析并在重试时绕过。""" adapter = _SyncAdapter() runtime = CapabilityRuntime( _registry(tmp_path), adapters={"sample": adapter}, ) with patch.object( adapter, "materialize", return_value=_SampleCapability, ) as materialize: with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): runtime.activate("sample.capability", reason="invalid_adapter") with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): runtime.activate( "sample.capability", reason="invalid_adapter_retry", retry=True, ) snapshot = runtime.snapshot("sample.capability") assert snapshot.materialization is CapabilityMaterializationState.FAILED assert snapshot.lifecycle is CapabilityLifecycleState.FAILED assert adapter.create_calls == 0 assert materialize.call_count == 2 @pytest.mark.asyncio async def test_activate_async_revalidates_missing_canonical_module_on_retry( tmp_path: Path, ) -> None: """异步激活的物化合同失败不得被记为已解析并在重试时绕过。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime( _registry(tmp_path), adapters={"sample": adapter}, ) materialize = AsyncMock(return_value=_SampleCapability) with patch.object(adapter, "materialize", new=materialize): with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): await runtime.activate_async("sample.capability", reason="invalid_adapter") with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"): await runtime.activate_async( "sample.capability", reason="invalid_adapter_retry", retry=True, ) snapshot = runtime.snapshot("sample.capability") assert snapshot.materialization is CapabilityMaterializationState.FAILED assert snapshot.lifecycle is CapabilityLifecycleState.FAILED assert adapter.create_calls == 0 assert materialize.await_count == 2 def test_state_read_calibrates_consumer_import_without_importing_new_module(tmp_path: Path) -> None: """显式旧导入存在时应复用 sys.modules 中的 canonical symbol。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) module = types.ModuleType("sample_implementation") canonical = type("SampleCapability", (), {}) module.SampleCapability = canonical with patch.dict(sys.modules, {"sample_implementation": module}): snapshot = runtime.snapshot("sample.capability") implementation = runtime.materialize( "sample.capability", reason="compat_lookup", ) assert snapshot.materialization is CapabilityMaterializationState.RESOLVED assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED assert snapshot.generation == 0 assert implementation is canonical assert adapter.materialize_calls == 0 def test_state_read_does_not_invoke_module_level_lazy_export(tmp_path: Path) -> None: """sys.modules 校准只能读模块字典,不能触发模块级 __getattr__。""" runtime = CapabilityRuntime( _registry(tmp_path), adapters={"sample": _SyncAdapter()}, ) module = types.ModuleType("sample_implementation") lazy_reads = [] def resolve(name: str) -> object: lazy_reads.append(name) raise AssertionError("state read must not resolve lazy exports") module.__getattr__ = resolve with patch.dict(sys.modules, {"sample_implementation": module}): snapshot = runtime.snapshot("sample.capability") assert snapshot.materialization is CapabilityMaterializationState.UNRESOLVED assert lazy_reads == [] def test_sync_activate_is_single_flight_and_publishes_only_after_start(tmp_path: Path) -> None: """并发首启只能创建一个候选实例,start 返回前普通查询不可见。""" adapter = _SyncAdapter() adapter.start_entered = threading.Event() adapter.start_release = threading.Event() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) results = [] errors = [] def activate() -> None: try: results.append(runtime.activate("sample.capability", reason="concurrent")) except BaseException as error: # pragma: no cover - diagnostic collection errors.append(error) first = threading.Thread(target=activate) second = threading.Thread(target=activate) first.start() assert adapter.start_entered.wait(timeout=5) second.start() assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STARTING adapter.start_release.set() first.join(timeout=5) second.join(timeout=5) assert not errors assert len(results) == 2 assert results[0] is results[1] assert adapter.materialize_calls == 1 assert adapter.create_calls == 1 assert adapter.start_calls == 1 assert runtime.snapshot("sample.capability").generation == 1 def test_failed_start_cleans_candidate_and_requires_explicit_retry(tmp_path: Path) -> None: """半初始化候选必须清理;FAILED 不得被普通 activate 隐式重试。""" adapter = _SyncAdapter() adapter.fail_start = True runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) with pytest.raises(CapabilityOperationError, match="start failed"): runtime.activate("sample.capability", reason="first_attempt") failed = runtime.snapshot("sample.capability") assert failed.materialization is CapabilityMaterializationState.RESOLVED assert failed.lifecycle is CapabilityLifecycleState.FAILED assert failed.visible is False assert adapter.cleanup_calls == 1 with pytest.raises(CapabilityOperationError, match="显式 retry"): runtime.activate("sample.capability", reason="implicit_retry") assert adapter.start_calls == 1 adapter.fail_start = False instance = runtime.activate("sample.capability", reason="explicit_retry", retry=True) assert instance.started is True assert runtime.snapshot("sample.capability").generation == 2 assert adapter.start_calls == 2 def test_adapter_must_return_candidate_before_start(tmp_path: Path) -> None: """create 没有候选对象时不得进入 start 或伪造 RUNNING 可见性。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) with patch.object(adapter, "create", return_value=None), pytest.raises( CapabilityOperationError, match="candidate", ): runtime.activate("sample.capability", reason="invalid_candidate") assert adapter.start_calls == 0 assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED assert runtime.get_running("sample.capability") is None def test_stop_withdraws_visibility_before_adapter_callback(tmp_path: Path) -> None: """释放外部资源可能阻塞,但运行实例必须在 stop 回调前撤销发布。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) instance = runtime.activate("sample.capability", reason="start") adapter.stop_entered = threading.Event() adapter.stop_release = threading.Event() stopper = threading.Thread( target=lambda: runtime.stop("sample.capability", reason="configuration_removed") ) stopper.start() assert adapter.stop_entered.wait(timeout=5) assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING adapter.stop_release.set() stopper.join(timeout=5) assert instance.stopped is True assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED def test_stop_failure_retains_ownership_until_same_instance_stops(tmp_path: Path) -> None: """stop 失败后的隐藏资源必须保留所有权,禁止用 retry 绕过清理。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) instance = runtime.activate("sample.capability", reason="start") adapter.fail_stop = True with pytest.raises(CapabilityOperationError, match="stop failed"): runtime.stop("sample.capability", reason="configuration_removed") assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED with pytest.raises(CapabilityOperationError, match="stop failed"): runtime.activate("sample.capability", reason="unsafe_retry", retry=True) assert adapter.create_calls == 1 adapter.fail_stop = False runtime.stop("sample.capability", reason="stop_retry") assert adapter.stop_instances == [instance, instance, instance] assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED replacement = runtime.activate("sample.capability", reason="after_release") assert replacement is not instance assert adapter.create_calls == 2 def test_reload_withdraws_old_instance_and_publishes_one_new_generation(tmp_path: Path) -> None: """同步 reload 在 stop/start 回调期间不暴露旧实例或半初始化候选。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) old_instance = runtime.activate("sample.capability", reason="initial") adapter.stop_entered = threading.Event() adapter.stop_release = threading.Event() results = [] reloader = threading.Thread( target=lambda: results.append(runtime.reload("sample.capability", reason="config_changed")) ) reloader.start() assert adapter.stop_entered.wait(timeout=5) assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.RELOADING adapter.stop_release.set() reloader.join(timeout=5) assert len(results) == 1 assert results[0] is not old_instance assert runtime.get_running("sample.capability") is results[0] assert runtime.snapshot("sample.capability").generation == 2 def test_failed_reload_cleans_candidate_and_keeps_instance_invisible(tmp_path: Path) -> None: """reload 新 generation 启动失败时不得恢复旧实例或发布候选。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) runtime.activate("sample.capability", reason="initial") adapter.fail_start = True with pytest.raises(CapabilityOperationError, match="start failed"): runtime.reload("sample.capability", reason="config_changed") snapshot = runtime.snapshot("sample.capability") assert snapshot.lifecycle is CapabilityLifecycleState.FAILED assert snapshot.visible is False assert adapter.cleanup_calls == 1 def test_reload_stop_failure_does_not_create_or_reuse_live_previous(tmp_path: Path) -> None: """reload 未释放旧资源时必须失败关闭,不能创建或复用同一活对象。""" adapter = _SyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) old_instance = runtime.activate("sample.capability", reason="initial") adapter.fail_stop = True with pytest.raises(CapabilityOperationError, match="stop failed"): runtime.reload("sample.capability", reason="config_changed") assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED assert adapter.stop_instances == [old_instance] assert adapter.create_calls == 1 assert adapter.start_calls == 1 with pytest.raises(CapabilityOperationError, match="重试 stop"): runtime.activate("sample.capability", reason="implicit_retry") adapter.fail_stop = False adapter.stop_entered = threading.Event() adapter.stop_release = threading.Event() recovered = [] recovery = threading.Thread( target=lambda: recovered.append( runtime.activate( "sample.capability", reason="recover_after_reload", retry=True, ) ) ) recovery.start() assert adapter.stop_entered.wait(timeout=5) assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING assert runtime.get_running("sample.capability") is None assert adapter.create_calls == 1 adapter.stop_release.set() recovery.join(timeout=5) assert len(recovered) == 1 replacement = recovered[0] assert adapter.stop_instances == [old_instance, old_instance] assert replacement is not old_instance assert adapter.create_calls == 2 def test_shutdown_prevents_inflight_start_from_resurrecting_instance(tmp_path: Path) -> None: """shutdown 与首启竞争时,候选只能清理,不能在关闭开始后重新发布。""" adapter = _SyncAdapter() adapter.start_entered = threading.Event() adapter.start_release = threading.Event() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) activate_errors = [] def activate() -> None: try: runtime.activate("sample.capability", reason="racing_start") except BaseException as error: activate_errors.append(error) starter = threading.Thread(target=activate) starter.start() assert adapter.start_entered.wait(timeout=5) closer = threading.Thread(target=lambda: runtime.shutdown(reason="application_shutdown")) closer.start() adapter.start_release.set() starter.join(timeout=5) closer.join(timeout=5) assert len(activate_errors) == 1 assert isinstance(activate_errors[0], CapabilityRuntimeClosedError) assert adapter.cleanup_calls == 1 assert runtime.get_running("sample.capability") is None assert runtime.is_shutdown is True with pytest.raises(CapabilityRuntimeClosedError): runtime.activate("sample.capability", reason="late_start") def test_shutdown_cannot_return_between_open_check_and_sync_claim(tmp_path: Path) -> None: """open check 与 inflight claim 必须共享 barrier,关闭扫描不能漏过首启。""" adapter = _SyncAdapter() adapter.start_entered = threading.Event() adapter.start_release = threading.Event() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) check_entered = threading.Event() check_release = threading.Event() shutdown_returned = threading.Event() activate_errors = [] original_ensure_open = runtime._ensure_open first_check = True check_lock = threading.Lock() def gated_ensure_open() -> None: nonlocal first_check original_ensure_open() with check_lock: should_wait = first_check first_check = False if should_wait: check_entered.set() assert check_release.wait(timeout=5) def activate() -> None: try: runtime.activate("sample.capability", reason="preclaim_race") except BaseException as error: activate_errors.append(error) def shutdown() -> None: runtime.shutdown(reason="application_shutdown") shutdown_returned.set() with patch.object(runtime, "_ensure_open", side_effect=gated_ensure_open): starter = threading.Thread(target=activate) starter.start() assert check_entered.wait(timeout=5) closer = threading.Thread(target=shutdown) closer.start() assert not shutdown_returned.wait(timeout=0.1) check_release.set() assert adapter.start_entered.wait(timeout=5) assert not shutdown_returned.is_set() adapter.start_release.set() starter.join(timeout=5) closer.join(timeout=5) assert shutdown_returned.is_set() assert len(activate_errors) <= 1 assert not activate_errors or isinstance( activate_errors[0], CapabilityRuntimeClosedError, ) assert runtime.get_running("sample.capability") is None @pytest.mark.asyncio async def test_shutdown_cannot_return_between_open_check_and_async_claim( tmp_path: Path, ) -> None: """异步 activate 的同步 claim 区间也必须受同一关闭 barrier 保护。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) check_entered = threading.Event() check_release = threading.Event() shutdown_returned = threading.Event() returned_before_release = [] closer_threads = [] original_ensure_open = runtime._ensure_open first_check = True check_lock = threading.Lock() def gated_ensure_open() -> None: nonlocal first_check original_ensure_open() with check_lock: should_wait = first_check first_check = False if should_wait: check_entered.set() assert check_release.wait(timeout=5) def shutdown() -> None: asyncio.run(runtime.shutdown_async(reason="application_shutdown")) shutdown_returned.set() def coordinate_shutdown() -> None: assert check_entered.wait(timeout=5) closer = threading.Thread(target=shutdown) closer_threads.append(closer) closer.start() returned_before_release.append(shutdown_returned.wait(timeout=0.1)) check_release.set() coordinator = threading.Thread(target=coordinate_shutdown) coordinator.start() with patch.object(runtime, "_ensure_open", side_effect=gated_ensure_open): activate_task = asyncio.create_task( runtime.activate_async("sample.capability", reason="preclaim_race") ) await adapter.start_entered.wait() await asyncio.to_thread(coordinator.join, 5) assert returned_before_release == [False] assert not shutdown_returned.is_set() adapter.start_release.set() try: await activate_task except CapabilityRuntimeClosedError: pass await asyncio.to_thread(closer_threads[0].join, 5) assert shutdown_returned.is_set() assert runtime.get_running("sample.capability") is None @pytest.mark.asyncio async def test_async_adapter_uses_same_single_flight_state_machine(tmp_path: Path) -> None: """异步回调等待不能阻塞事件循环,并发调用共享同一 generation。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) first = asyncio.create_task(runtime.activate_async("sample.capability", reason="first")) await adapter.start_entered.wait() second = asyncio.create_task(runtime.activate_async("sample.capability", reason="second")) await asyncio.sleep(0) assert runtime.get_running("sample.capability") is None adapter.start_release.set() first_instance, second_instance = await asyncio.gather(first, second) assert first_instance is second_instance assert adapter.materialize_calls == 1 assert adapter.start_calls == 1 assert runtime.snapshot("sample.capability").generation == 1 @pytest.mark.asyncio async def test_stop_async_failure_retains_ownership_for_explicit_retry(tmp_path: Path) -> None: """异步 stop 失败后只能重试释放同一实例,不能直接启动新实例。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial")) await adapter.start_entered.wait() adapter.start_release.set() instance = await initial adapter.fail_stop = True with pytest.raises(CapabilityOperationError, match="async stop failed"): await runtime.stop_async("sample.capability", reason="configuration_removed") with pytest.raises(CapabilityOperationError, match="async stop failed"): await runtime.activate_async("sample.capability", reason="unsafe_retry", retry=True) assert adapter.create_calls == 1 adapter.fail_stop = False await runtime.stop_async("sample.capability", reason="stop_retry") assert adapter.stop_instances == [instance, instance, instance] assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED @pytest.mark.asyncio async def test_shutdown_async_reports_unreleased_owner_until_retry(tmp_path: Path) -> None: """异步 shutdown 不得把 stop 失败伪装成整体成功。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) activation = asyncio.create_task( runtime.activate_async("sample.capability", reason="initial") ) await adapter.start_entered.wait() adapter.start_release.set() instance = await activation adapter.fail_stop = True assert await runtime.shutdown_async(reason="application_shutdown") is False assert runtime.snapshot( "sample.capability" ).lifecycle is CapabilityLifecycleState.FAILED adapter.fail_stop = False assert await runtime.shutdown_async(reason="shutdown_retry") is True assert adapter.stop_instances == [instance, instance] @pytest.mark.asyncio async def test_async_reload_uses_reloading_state_and_hides_candidate(tmp_path: Path) -> None: """异步 reload 与同步入口遵守相同状态和发布边界。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial")) await adapter.start_entered.wait() adapter.start_release.set() old_instance = await initial adapter.start_entered = asyncio.Event() adapter.start_release = asyncio.Event() reload_task = asyncio.create_task( runtime.reload_async("sample.capability", reason="config_changed") ) await adapter.start_entered.wait() assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.RELOADING adapter.start_release.set() new_instance = await reload_task assert new_instance is not old_instance assert runtime.get_running("sample.capability") is new_instance assert runtime.snapshot("sample.capability").generation == 2 @pytest.mark.asyncio async def test_failed_async_reload_cleans_candidate_and_enters_failed(tmp_path: Path) -> None: """异步 reload 失败与同步入口一致,不发布半初始化候选。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial")) await adapter.start_entered.wait() adapter.start_release.set() await initial adapter.start_entered = asyncio.Event() adapter.start_release = asyncio.Event() adapter.fail_start = True reload_task = asyncio.create_task( runtime.reload_async("sample.capability", reason="config_changed") ) await adapter.start_entered.wait() adapter.start_release.set() with pytest.raises(CapabilityOperationError, match="async start failed"): await reload_task snapshot = runtime.snapshot("sample.capability") assert snapshot.lifecycle is CapabilityLifecycleState.FAILED assert snapshot.visible is False assert adapter.cleanup_calls == 1 @pytest.mark.asyncio async def test_async_reload_stop_failure_retains_previous_without_new_create( tmp_path: Path, ) -> None: """异步 reload 也必须保留未释放旧实例并禁止创建第二份资源。""" adapter = _AsyncAdapter() runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) initial = asyncio.create_task(runtime.activate_async("sample.capability", reason="initial")) await adapter.start_entered.wait() adapter.start_release.set() old_instance = await initial adapter.fail_stop = True with pytest.raises(CapabilityOperationError, match="async stop failed"): await runtime.reload_async("sample.capability", reason="config_changed") assert runtime.get_running("sample.capability") is None assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.FAILED assert adapter.stop_instances == [old_instance] assert adapter.create_calls == 1 assert adapter.start_calls == 1 with pytest.raises(CapabilityOperationError, match="重试 stop"): await runtime.activate_async("sample.capability", reason="implicit_retry") adapter.fail_stop = False adapter.start_entered = asyncio.Event() adapter.start_release = asyncio.Event() adapter.stop_entered = asyncio.Event() adapter.stop_release = asyncio.Event() recovery = asyncio.create_task( runtime.activate_async( "sample.capability", reason="recover_after_reload", retry=True, ) ) await adapter.stop_entered.wait() assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPING assert runtime.get_running("sample.capability") is None assert adapter.create_calls == 1 adapter.stop_release.set() await adapter.start_entered.wait() adapter.start_release.set() replacement = await recovery assert adapter.stop_instances == [old_instance, old_instance] assert replacement is not old_instance assert adapter.create_calls == 2 def test_one_failed_capability_does_not_remove_specs_or_block_other_capabilities( tmp_path: Path, ) -> None: """单项失败只改变自身状态,Registry 中的其它声明仍可继续运行。""" first_dir = tmp_path / "first" second_dir = tmp_path / "second" first_dir.mkdir() second_dir.mkdir() (first_dir / "capability.toml").write_text(_MANIFEST.strip() + "\n", encoding="utf-8") (second_dir / "capability.toml").write_text( _MANIFEST.replace("sample.capability", "other.capability") .replace("sample_implementation", "other_implementation") .strip() + "\n", encoding="utf-8", ) registry = CapabilityRegistry.discover( roots=[tmp_path], kinds={"sample"}, selector_schemas={}, ) adapter = _SyncAdapter() runtime = CapabilityRuntime(registry, adapters={"sample": adapter}) adapter.fail_start = True with pytest.raises(CapabilityOperationError): runtime.activate("sample.capability", reason="fail") adapter.fail_start = False other = runtime.activate("other.capability", reason="continue") assert other.started is True assert {spec.id for spec in runtime.list_specs()} == { "sample.capability", "other.capability", } assert runtime.snapshot("sample.capability").error == "start failed" @pytest.mark.asyncio async def test_sync_and_async_entrypoints_reject_wrong_adapter_mode(tmp_path: Path) -> None: """入口与 adapter 执行模型不匹配时应在执行回调前失败。""" async_runtime = CapabilityRuntime( _registry(tmp_path / "async"), adapters={"sample": _AsyncAdapter()}, ) with pytest.raises(CapabilityAdapterModeError): async_runtime.activate("sample.capability", reason="wrong_mode") sync_runtime = CapabilityRuntime( _registry(tmp_path / "sync"), adapters={"sample": _SyncAdapter()}, ) with pytest.raises(CapabilityAdapterModeError): await sync_runtime.activate_async("sample.capability", reason="wrong_mode") def test_adapter_mode_requires_declared_enum_member(tmp_path: Path) -> None: """并发模型必须显式声明 enum,不能依赖字符串相等的偶然兼容。""" adapter = _SyncAdapter() adapter.execution_mode = "sync" runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter}) with pytest.raises(CapabilityAdapterModeError): runtime.activate("sample.capability", reason="invalid_mode")