From 424b07429cbdbd17911b6214f4cc9acd9137de48 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 14 Oct 2026 16:16:57 +0800 Subject: [PATCH 1/3] fix(periodic-report): name declared source adapters that failed activation `discover_extension_hook_adapters` already isolates every lifecycle, permission, factory-import and port-mismatch failure as a content-free record, but `discover_periodic_report_request_ports` read only `ports` and dropped `failures`. An operator whose Lark adapter was declared yet did not activate saw "source adapter is unavailable" with no adapter id, no code and no next step, indistinguishable from having no provider installed. An unreadable extension state produced the same empty result. Carry the failures through: `PeriodicReportRequestPorts` keeps a content-free `failed_adapters` map (declared-but-failed, ambiguous ports, incomplete ports) and the discovery-level `state_error_code`; selection names the failed adapter and points at `loopx extension doctor`. Discovery reports an unreadable state file as `extension_state_unavailable` instead of an empty catalog. Receipts and journal entries are unchanged; only the refusal text gains the missing identity and next action. Signed-off-by: song --- loopx/capabilities/periodic_report/README.md | 7 +- .../periodic_report/request_action.py | 57 ++++++++++- loopx/extensions/hook_adapters.py | 14 ++- tests/extensions/test_extension_runtime.py | 18 ++++ .../test_lark_periodic_report_request.py | 98 +++++++++++++++++++ 5 files changed, 186 insertions(+), 8 deletions(-) diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md index 8004ea1ee3..aa5a9948e2 100644 --- a/loopx/capabilities/periodic_report/README.md +++ b/loopx/capabilities/periodic_report/README.md @@ -74,7 +74,12 @@ automatically. With multiple active providers, the Agent selects the provider explicitly with `--source-adapter-id `. The journal retains that adapter identity, and later settlement resolves only that owner regardless of extension discovery order. A temporarily unavailable owner leaves the request -pending; LoopX never falls through to another provider. +pending; LoopX never falls through to another provider. A declared adapter +whose extension did not activate (disabled, doctor-stale, API-incompatible, +factory import or port mismatch) is reported by adapter id with a content-free +code and the `loopx extension doctor` next step; it is never silently dropped, +and an unreadable extension state is reported as such rather than as "no +provider installed". The adapter id is part of the request idempotency namespace together with the Goal, Agent, and provider-local source reference. Two providers may therefore diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index 564b3d1de1..6a41f87275 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -5,7 +5,7 @@ import re import sys from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone from importlib import import_module from pathlib import Path @@ -72,11 +72,38 @@ class PeriodicReportRequestAdapter: @dataclass(frozen=True, slots=True) class PeriodicReportRequestPorts: adapters: Mapping[str, PeriodicReportRequestAdapter] + # Declared adapters that matched this hook but did not activate, keyed by + # adapter id with a content-free code. Selection names them instead of + # reporting a merely absent provider. + failed_adapters: Mapping[str, str] = field(default_factory=dict) + state_error_code: str | None = None @property def adapter_ids(self) -> tuple[str, ...]: return tuple(sorted(self.adapters)) + def _unavailable_detail(self, selected_id: str | None = None) -> str: + if selected_id and selected_id in self.failed_adapters: + return ( + f": {selected_id} is declared but failed activation " + f"({self.failed_adapters[selected_id]}); inspect " + "`loopx extension doctor --execute`" + ) + if selected_id: + return f": {selected_id}" + if self.state_error_code: + return f": extension state could not be read ({self.state_error_code})" + if self.failed_adapters: + failed = ", ".join( + f"{adapter_id} ({code})" + for adapter_id, code in sorted(self.failed_adapters.items()) + ) + return ( + f": declared adapters failed activation: {failed}; inspect " + "`loopx extension doctor --execute`" + ) + return "" + def select_source_adapter( self, adapter_id: str | None ) -> PeriodicReportRequestAdapter: @@ -85,14 +112,17 @@ def select_source_adapter( selected = self.adapters.get(selected_id) if selected is None: raise ValueError( - "periodic-report request source adapter is unavailable: " - f"{selected_id}" + "periodic-report request source adapter is unavailable" + + self._unavailable_detail(selected_id) ) return selected if len(self.adapters) == 1: return next(iter(self.adapters.values())) if not self.adapters: - raise ValueError("periodic-report request source adapter is unavailable") + raise ValueError( + "periodic-report request source adapter is unavailable" + + self._unavailable_detail() + ) raise ValueError( "periodic-report request source adapter is ambiguous; " "select one with --source-adapter-id" @@ -187,7 +217,24 @@ def discover_periodic_report_request_ports( if adapter_id not in ambiguous_adapter_ids and set(ports) == {REQUEST_BIND_PORT, REQUEST_SETTLE_PORT} } - return PeriodicReportRequestPorts(adapters=complete) + failed_adapters = { + failure.adapter_id: failure.error_code + for failure in discovery.failures + if failure.adapter_id not in complete + } + for adapter_id, ports in sorted(adapter_ports.items()): + if adapter_id in complete or adapter_id in failed_adapters: + continue + failed_adapters[adapter_id] = ( + "ambiguous_adapter_ports" + if adapter_id in ambiguous_adapter_ids + else "incomplete_adapter_ports" + ) + return PeriodicReportRequestPorts( + adapters=complete, + failed_adapters=failed_adapters, + state_error_code=getattr(discovery, "state_error_code", None), + ) def _normalize_source_receipt( diff --git a/loopx/extensions/hook_adapters.py b/loopx/extensions/hook_adapters.py index 5b321d5841..1fd141d46f 100644 --- a/loopx/extensions/hook_adapters.py +++ b/loopx/extensions/hook_adapters.py @@ -39,10 +39,18 @@ class ExtensionHookAdapterFailure: @dataclass(frozen=True, slots=True) class ExtensionHookAdapterDiscovery: - """Activated provider ports plus isolated content-free failures.""" + """Activated provider ports plus isolated content-free failures. + + `failures` names each declared adapter that matched the requested hook but + could not be activated (lifecycle, permission, factory import or port + mismatch). `state_error_code` is set when the extension state itself could + not be read, so an empty `ports` is distinguishable from "no provider is + installed". Neither carries provider output or exception text. + """ ports: tuple[ExtensionHookAdapterPortBinding, ...] failures: tuple[ExtensionHookAdapterFailure, ...] + state_error_code: str | None = None def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., Any]]]: @@ -88,7 +96,9 @@ def discover_extension_hook_adapters( try: catalog = extension_catalog_entries(state_file=state_file) except (OSError, TypeError, ValueError): - return ExtensionHookAdapterDiscovery(ports=(), failures=()) + return ExtensionHookAdapterDiscovery( + ports=(), failures=(), state_error_code="extension_state_unavailable" + ) ports: list[ExtensionHookAdapterPortBinding] = [] failures: list[ExtensionHookAdapterFailure] = [] diff --git a/tests/extensions/test_extension_runtime.py b/tests/extensions/test_extension_runtime.py index 90d8f81e60..24f5fc4104 100644 --- a/tests/extensions/test_extension_runtime.py +++ b/tests/extensions/test_extension_runtime.py @@ -281,10 +281,28 @@ def test_capability_action_factory_failure_is_content_free_and_kernel_independen ) assert discovery.ports == () + assert discovery.state_error_code is None assert len(discovery.failures) == 1 assert discovery.failures[0].adapter_id == "sample-report-source" assert discovery.failures[0].error_code == "extension_hook_adapter_unavailable" + # An unreadable state file is a typed discovery-level condition, not an + # empty catalog: callers can tell "no provider installed" from "state broken". + state_file.write_text("{not json", encoding="utf-8") + broken = discover_extension_hook_adapters( + state_file=state_file, + phase="capability_action", + capability_id="sample-report", + target_hook_id="sample_report.request", + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="sample-goal", + agent_id="sample-agent", + ) + assert broken.ports == () + assert broken.failures == () + assert broken.state_error_code == "extension_state_unavailable" + @pytest.mark.parametrize( "extension_id", diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 7b8c2a77a9..2806d9e72a 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -270,6 +270,104 @@ def settle_source(**_kwargs: object) -> dict[str, object]: ports.select_source_adapter(None) +def test_request_ports_name_declared_adapters_that_failed_activation( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A declared adapter that did not activate is reported, not dropped. + + Discovery already isolates factory/lifecycle failures as content-free + records; the request path used to discard them, so an operator saw only + "source adapter is unavailable" with no adapter id or next action. + """ + from loopx.extensions.hook_adapters import ExtensionHookAdapterFailure + + def bind_source(**_kwargs: object) -> dict[str, object]: + return {} + + def settle_source(**_kwargs: object) -> dict[str, object]: + return {} + + def port(adapter_id: str, port_name: str) -> ExtensionHookAdapterPortBinding: + return ExtensionHookAdapterPortBinding( + extension_id=f"fixture-{adapter_id}", + adapter_id=adapter_id, + capability_id="periodic-report", + target_hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + port_name=port_name, + handler=bind_source if port_name == REQUEST_BIND_PORT else settle_source, + ) + + failure = ExtensionHookAdapterFailure( + extension_id="fixture-source-broken", + adapter_id="source-broken", + capability_id="periodic-report", + target_hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + ) + monkeypatch.setattr( + request_action, + "discover_extension_hook_adapters", + lambda **_kwargs: ExtensionHookAdapterDiscovery( + ports=( + port("source-partial", REQUEST_BIND_PORT), + port("source-dup", REQUEST_BIND_PORT), + port("source-dup", REQUEST_BIND_PORT), + port("source-dup", REQUEST_SETTLE_PORT), + ), + failures=(failure,), + ), + ) + ports = discover_periodic_report_request_ports( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + + assert ports.adapter_ids == () + assert ports.failed_adapters == { + "source-broken": "extension_hook_adapter_unavailable", + "source-dup": "ambiguous_adapter_ports", + "source-partial": "incomplete_adapter_ports", + } + assert ports.state_error_code is None + with pytest.raises(ValueError) as named: + ports.select_source_adapter("source-broken") + assert "source-broken is declared but failed activation" in str(named.value) + assert "extension_hook_adapter_unavailable" in str(named.value) + assert "loopx extension doctor" in str(named.value) + with pytest.raises(ValueError) as unknown: + ports.select_source_adapter("never-declared") + assert str(unknown.value).endswith(": never-declared") + with pytest.raises(ValueError) as unselected: + ports.select_source_adapter(None) + assert "declared adapters failed activation" in str(unselected.value) + assert "source-broken (extension_hook_adapter_unavailable)" in str(unselected.value) + assert "source-dup (ambiguous_adapter_ports)" in str(unselected.value) + + +def test_request_ports_name_unreadable_extension_state( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + request_action, + "discover_extension_hook_adapters", + lambda **_kwargs: ExtensionHookAdapterDiscovery( + ports=(), failures=(), state_error_code="extension_state_unavailable" + ), + ) + ports = discover_periodic_report_request_ports( + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + assert ports.state_error_code == "extension_state_unavailable" + with pytest.raises(ValueError, match="extension state could not be read"): + ports.select_source_adapter(None) + + def test_lark_inbox_rejects_same_name_mention_with_different_provider_identity() -> ( None ): From b4fcf04a0bac6656b4760d5803208ba108f5391f Mon Sep 17 00:00:00 2001 From: song Date: Thu, 15 Oct 2026 00:18:28 +0800 Subject: [PATCH 2/3] fix(extensions): name disabled and doctor-stale hook adapter declarations Hook-adapter discovery skipped every catalog entry that was not ready before matching its declarations, so a disabled or doctor-stale extension left no failure record and the periodic-report request fell back to the generic "source adapter is unavailable" refusal. Match each declaration against the requested phase, capability and hook first, then record lifecycle non-admission as a content-free failure that reuses the extension readiness statuses `extension_disabled` and `extension_doctor_not_ready`, now named once in the runtime owner and shared with optional capability resolution. These branches never resolve activation, import the factory or run the doctor; ready extensions keep the existing activation path and `extension_hook_adapter_unavailable`. Signed-off-by: song --- docs/reference/extensions.md | 5 +- loopx/extensions/hook_adapters.py | 64 ++++++++++--- loopx/extensions/runtime.py | 9 +- tests/extensions/test_extension_runtime.py | 105 +++++++++++++++++++++ 4 files changed, 169 insertions(+), 14 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 29d9abc683..c2fcf90a50 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -784,7 +784,10 @@ manifest declarations and admits a factory only when the extension is enabled, doctor-ready, and authorized for every declared adapter permission. The factory must return exactly the declared callable ports. Import, activation, and factory failures become content-free optional-adapter failures; they do not -execute or replace kernel logic. +execute or replace kernel logic. A matching declaration of a disabled or +doctor-stale extension is also reported, as `extension_disabled` or +`extension_doctor_not_ready`, without importing its factory or running its +doctor. `phase = "capability_action"` is for an explicit typed action whose semantics have already been decided by the Agent or caller. The adapter may bind and diff --git a/loopx/extensions/hook_adapters.py b/loopx/extensions/hook_adapters.py index 1fd141d46f..75429bae09 100644 --- a/loopx/extensions/hook_adapters.py +++ b/loopx/extensions/hook_adapters.py @@ -6,12 +6,18 @@ from pathlib import Path from typing import Any, cast -from .runtime import extension_catalog_entries, resolve_extension_activation +from .runtime import ( + EXTENSION_DISABLED_STATUS, + EXTENSION_DOCTOR_NOT_READY_STATUS, + extension_catalog_entries, + resolve_extension_activation, +) HOOK_ADAPTER_FACTORY_CONTEXT_SCHEMA_VERSION = ( "loopx_extension_hook_adapter_factory_context_v0" ) +HOOK_ADAPTER_UNAVAILABLE = "extension_hook_adapter_unavailable" @dataclass(frozen=True, slots=True) @@ -27,14 +33,20 @@ class ExtensionHookAdapterPortBinding: @dataclass(frozen=True, slots=True) class ExtensionHookAdapterFailure: - """Content-free failure for one optional manifest adapter.""" + """Content-free failure for one optional manifest adapter. + + `error_code` is the extension readiness status when lifecycle refused + admission (disabled or doctor-stale), otherwise + `extension_hook_adapter_unavailable` for a ready extension whose + activation, factory or ports failed. + """ extension_id: str adapter_id: str capability_id: str target_hook_id: str phase: str - error_code: str = "extension_hook_adapter_unavailable" + error_code: str = HOOK_ADAPTER_UNAVAILABLE @dataclass(frozen=True, slots=True) @@ -42,8 +54,9 @@ class ExtensionHookAdapterDiscovery: """Activated provider ports plus isolated content-free failures. `failures` names each declared adapter that matched the requested hook but - could not be activated (lifecycle, permission, factory import or port - mismatch). `state_error_code` is set when the extension state itself could + was not activated: a disabled or doctor-stale extension (never imported), + or a ready one whose permission, factory import or ports failed. + `state_error_code` is set when the extension state itself could not be read, so an empty `ports` is distinguishable from "no provider is installed". Neither carries provider output or exception text. """ @@ -64,7 +77,10 @@ def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., An def _failure( - *, extension_id: str, adapter: Mapping[str, Any] + *, + extension_id: str, + adapter: Mapping[str, Any], + error_code: str = HOOK_ADAPTER_UNAVAILABLE, ) -> ExtensionHookAdapterFailure: return ExtensionHookAdapterFailure( extension_id=extension_id, @@ -72,9 +88,20 @@ def _failure( capability_id=str(adapter["capability_id"]), target_hook_id=str(adapter["target_hook_id"]), phase=str(adapter["phase"]), + error_code=error_code, ) +def _lifecycle_non_admission(provider: Mapping[str, Any]) -> str | None: + """Readiness status that keeps an installed extension from activating.""" + + if provider.get("ready") is True: + return None + if provider.get("enabled") is not True: + return EXTENSION_DISABLED_STATUS + return EXTENSION_DOCTOR_NOT_READY_STATUS + + def discover_extension_hook_adapters( *, state_file: str | Path, @@ -86,9 +113,12 @@ def discover_extension_hook_adapters( goal_id: str, agent_id: str | None, ) -> ExtensionHookAdapterDiscovery: - """Discover ready manifest-declared adapters without provider imports. + """Discover manifest-declared adapters, activating only ready extensions. Factories are imported only after lifecycle and permission activation. + A matching declaration of a disabled or doctor-stale extension is named + as a failure without resolving activation, importing its factory or + running its doctor. Factory/import/activation failures remain ordinary data so an optional provider cannot alter the capability or control-plane kernel. """ @@ -104,12 +134,15 @@ def discover_extension_hook_adapters( failures: list[ExtensionHookAdapterFailure] = [] for manifest in catalog: provider = manifest.get("provider") - if not isinstance(provider, Mapping) or provider.get("ready") is not True: - continue - extension_id = str(provider.get("id") or "") adapters = manifest.get("hook_adapters") - if not isinstance(adapters, list): + if not isinstance(provider, Mapping) or not isinstance(adapters, list): + continue + if provider.get("installed") is not True: + # A declared-only manifest is "no provider installed", not a + # disabled one; lifecycle codes below describe installed copies. continue + extension_id = str(provider.get("id") or "") + non_admission = _lifecycle_non_admission(provider) for adapter in adapters: if ( not isinstance(adapter, Mapping) @@ -118,6 +151,15 @@ def discover_extension_hook_adapters( or adapter.get("target_hook_id") != target_hook_id ): continue + if non_admission is not None: + failures.append( + _failure( + extension_id=extension_id, + adapter=adapter, + error_code=non_admission, + ) + ) + continue try: activation = resolve_extension_activation( extension_id, diff --git a/loopx/extensions/runtime.py b/loopx/extensions/runtime.py index dd0d2d5e00..59039a8dba 100644 --- a/loopx/extensions/runtime.py +++ b/loopx/extensions/runtime.py @@ -32,6 +32,11 @@ EXTENSION_ACTIVATION_SCHEMA_VERSION = "loopx_extension_activation_v0" EXTENSION_RUN_SCHEMA_VERSION = "loopx_extension_run_receipt_v0" EXTENSION_DOCTOR_BATCH_SCHEMA_VERSION = "loopx_extension_doctor_batch_v0" +# Content-free lifecycle non-admission codes. Optional capability resolution +# reports them as `ExtensionCapabilityResolution.status`; hook-adapter discovery +# reuses them for declared adapters whose extension it does not activate. +EXTENSION_DISABLED_STATUS = "extension_disabled" +EXTENSION_DOCTOR_NOT_READY_STATUS = "extension_doctor_not_ready" MAX_REVISIONS = 5 # Requalification is required on eviction; unprobed artifacts never activate. MAX_VERIFIED_ENTRYPOINTS = 32 @@ -895,7 +900,7 @@ def _optional_binding_for_entry( ) if not entry.get("enabled"): return _optional_binding_resolution( - status="extension_disabled", + status=EXTENSION_DISABLED_STATUS, extension_id=extension_id, installed=True, enabled=False, @@ -904,7 +909,7 @@ def _optional_binding_for_entry( ) if verified_entrypoint is None: return _optional_binding_resolution( - status="extension_doctor_not_ready", + status=EXTENSION_DOCTOR_NOT_READY_STATUS, extension_id=extension_id, installed=True, enabled=True, diff --git a/tests/extensions/test_extension_runtime.py b/tests/extensions/test_extension_runtime.py index 24f5fc4104..f10aee3332 100644 --- a/tests/extensions/test_extension_runtime.py +++ b/tests/extensions/test_extension_runtime.py @@ -304,6 +304,111 @@ def test_capability_action_factory_failure_is_content_free_and_kernel_independen assert broken.state_error_code == "extension_state_unavailable" +def test_capability_action_lifecycle_non_admission_is_named_without_factory_import( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from loopx.extensions import hook_adapters + + provider = _provider(tmp_path / "provider") + manifest_path = _standalone_manifest( + tmp_path / "extension.toml", + entrypoint=provider, + permission="semantic_preference.read", + ) + manifest_path.write_text( + manifest_path.read_text(encoding="utf-8") + + """ + +[[hook_adapters]] +id = "sample-report-source" +capability_id = "sample-report" +target_hook_id = "sample_report.request" +phase = "capability_action" +factory = "sample_extension.hooks:build_adapter" +required_permissions = ["semantic_preference.read"] +ports = ["sample_report.request.bind_source"] + +[[hook_adapters]] +id = "other-hook-source" +capability_id = "sample-report" +target_hook_id = "sample_report.other" +phase = "capability_action" +factory = "sample_extension.hooks:build_adapter" +required_permissions = ["semantic_preference.read"] +ports = ["sample_report.other.bind_source"] +""", + encoding="utf-8", + ) + state_file = tmp_path / "runtime" / "extensions" / "state.json" + install_extension(manifest_path, state_file=state_file, execute=True) + factory_loads: list[str] = [] + + def record_factory_load(reference: str) -> object: + factory_loads.append(reference) + raise ValueError("synthetic factory is not importable") + + monkeypatch.setattr(hook_adapters, "_load_factory", record_factory_load) + + def discover() -> object: + return discover_extension_hook_adapters( + state_file=state_file, + phase="capability_action", + capability_id="sample-report", + target_hook_id="sample_report.request", + registry_path=tmp_path / "registry.json", + runtime_root=tmp_path / "runtime", + goal_id="sample-goal", + agent_id="sample-agent", + ) + + def named_failures(discovery: object) -> list[tuple[str, str, str]]: + return [ + (failure.extension_id, failure.adapter_id, failure.error_code) + for failure in discovery.failures + ] + + # Positive control: a ready extension reaches the factory loader. + ready = discover() + assert named_failures(ready) == [ + ( + "test-standalone-extension", + "sample-report-source", + "extension_hook_adapter_unavailable", + ) + ] + assert factory_loads == ["sample_extension.hooks:build_adapter"] + factory_loads.clear() + + disable_extension("test-standalone-extension", state_file=state_file, execute=True) + disabled_state = state_file.read_bytes() + disabled = discover() + assert disabled.ports == () + assert disabled.state_error_code is None + assert named_failures(disabled) == [ + ("test-standalone-extension", "sample-report-source", "extension_disabled") + ] + assert factory_loads == [] + assert state_file.read_bytes() == disabled_state + + enable_extension("test-standalone-extension", state_file=state_file, execute=True) + # Changing the doctor-verified executable invalidates its runtime identity. + _provider(provider, doctor_exit=2) + stale_state = state_file.read_bytes() + stale = discover() + assert stale.ports == () + assert stale.state_error_code is None + assert named_failures(stale) == [ + ( + "test-standalone-extension", + "sample-report-source", + "extension_doctor_not_ready", + ) + ] + assert factory_loads == [] + assert state_file.read_bytes() == stale_state + + @pytest.mark.parametrize( "extension_id", [ From 361ddf1b789ac1ee196d84eb635e3159af712ce6 Mon Sep 17 00:00:00 2001 From: song Date: Thu, 15 Oct 2026 00:19:54 +0800 Subject: [PATCH 3/3] fix(periodic-report): keep state loss visible under explicit source selectors With `--source-adapter-id`, an unreadable extension state was reported only as "unavailable: ", hiding the state loss that the omitted selector already named. Report `extension_state_unavailable` before the generic unknown-id detail. A named failed id keeps its own diagnostic; legal selection, journal ownership, settlement and the no-fallback refusal are unchanged. Word non-activated declarations as "not activated", since a disabled one never attempted activation, and narrow the README to the codes that are delivered (the API-incompatible claim had no producer). Cover the real installed lifecycle through the CLI: disable, doctor-identity invalidation and corrupt state across omitted, known and unknown selectors, asserting no factory import and unchanged runtime files, plus the plain refusal when no extension is installed. Signed-off-by: song --- loopx/capabilities/periodic_report/README.md | 14 +- .../periodic_report/request_action.py | 18 +- .../test_lark_periodic_report_request.py | 207 +++++++++++++++++- 3 files changed, 225 insertions(+), 14 deletions(-) diff --git a/loopx/capabilities/periodic_report/README.md b/loopx/capabilities/periodic_report/README.md index aa5a9948e2..e703980ab6 100644 --- a/loopx/capabilities/periodic_report/README.md +++ b/loopx/capabilities/periodic_report/README.md @@ -75,11 +75,15 @@ explicitly with `--source-adapter-id `. The journal retains that adapter identity, and later settlement resolves only that owner regardless of extension discovery order. A temporarily unavailable owner leaves the request pending; LoopX never falls through to another provider. A declared adapter -whose extension did not activate (disabled, doctor-stale, API-incompatible, -factory import or port mismatch) is reported by adapter id with a content-free -code and the `loopx extension doctor` next step; it is never silently dropped, -and an unreadable extension state is reported as such rather than as "no -provider installed". +that was not activated is reported by adapter id with a content-free code and +the `loopx extension doctor` next step instead of being silently dropped: +`extension_disabled` or `extension_doctor_not_ready` for a disabled or +doctor-stale extension, whose factory is never imported, and +`extension_hook_adapter_unavailable` (or an incomplete/ambiguous port code) +when a ready extension fails its permission check, factory import or port +contract. An unreadable extension state is reported as +`extension_state_unavailable`, with or without `--source-adapter-id`, rather +than as "no provider installed". The adapter id is part of the request idempotency namespace together with the Goal, Agent, and provider-local source reference. Two providers may therefore diff --git a/loopx/capabilities/periodic_report/request_action.py b/loopx/capabilities/periodic_report/request_action.py index 6a41f87275..b4013bd1d5 100644 --- a/loopx/capabilities/periodic_report/request_action.py +++ b/loopx/capabilities/periodic_report/request_action.py @@ -72,8 +72,8 @@ class PeriodicReportRequestAdapter: @dataclass(frozen=True, slots=True) class PeriodicReportRequestPorts: adapters: Mapping[str, PeriodicReportRequestAdapter] - # Declared adapters that matched this hook but did not activate, keyed by - # adapter id with a content-free code. Selection names them instead of + # Declared adapters that matched this hook but were not activated, keyed + # by adapter id with a content-free code. Selection names them instead of # reporting a merely absent provider. failed_adapters: Mapping[str, str] = field(default_factory=dict) state_error_code: str | None = None @@ -85,21 +85,27 @@ def adapter_ids(self) -> tuple[str, ...]: def _unavailable_detail(self, selected_id: str | None = None) -> str: if selected_id and selected_id in self.failed_adapters: return ( - f": {selected_id} is declared but failed activation " + f": {selected_id} is declared but was not activated " f"({self.failed_adapters[selected_id]}); inspect " "`loopx extension doctor --execute`" ) + if self.state_error_code: + # An unreadable state hides every declaration, so an explicit id + # can be neither confirmed nor ruled out: report the loss itself. + selected = f" {selected_id};" if selected_id else "" + return ( + f":{selected} extension state could not be read " + f"({self.state_error_code})" + ) if selected_id: return f": {selected_id}" - if self.state_error_code: - return f": extension state could not be read ({self.state_error_code})" if self.failed_adapters: failed = ", ".join( f"{adapter_id} ({code})" for adapter_id, code in sorted(self.failed_adapters.items()) ) return ( - f": declared adapters failed activation: {failed}; inspect " + f": declared adapters were not activated: {failed}; inspect " "`loopx extension doctor --execute`" ) return "" diff --git a/tests/extensions/test_lark_periodic_report_request.py b/tests/extensions/test_lark_periodic_report_request.py index 2806d9e72a..c89cd88052 100644 --- a/tests/extensions/test_lark_periodic_report_request.py +++ b/tests/extensions/test_lark_periodic_report_request.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import json +import sys from pathlib import Path import pytest @@ -23,7 +25,7 @@ ) from loopx.extensions.lark.goal_channel_targets import add_lark_goal_channel_target from loopx.extensions.lark.event_inbox import _event_from_payload -from loopx.extensions.runtime import install_extension +from loopx.extensions.runtime import disable_extension, install_extension from loopx.extensions.lark import periodic_report_request @@ -334,7 +336,7 @@ def port(adapter_id: str, port_name: str) -> ExtensionHookAdapterPortBinding: assert ports.state_error_code is None with pytest.raises(ValueError) as named: ports.select_source_adapter("source-broken") - assert "source-broken is declared but failed activation" in str(named.value) + assert "source-broken is declared but was not activated" in str(named.value) assert "extension_hook_adapter_unavailable" in str(named.value) assert "loopx extension doctor" in str(named.value) with pytest.raises(ValueError) as unknown: @@ -342,7 +344,7 @@ def port(adapter_id: str, port_name: str) -> ExtensionHookAdapterPortBinding: assert str(unknown.value).endswith(": never-declared") with pytest.raises(ValueError) as unselected: ports.select_source_adapter(None) - assert "declared adapters failed activation" in str(unselected.value) + assert "declared adapters were not activated" in str(unselected.value) assert "source-broken (extension_hook_adapter_unavailable)" in str(unselected.value) assert "source-dup (ambiguous_adapter_ports)" in str(unselected.value) @@ -368,6 +370,205 @@ def test_request_ports_name_unreadable_extension_state( ports.select_source_adapter(None) +_PROBE_FACTORY_MODULE = "loopx_probe_periodic_report_source_hooks" + + +def _install_probe_source_extension( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[Path, Path]: + """Install an effect-free source extension through the real lifecycle.""" + + package_root = tmp_path / "probe-package" + package_root.mkdir() + (package_root / f"{_PROBE_FACTORY_MODULE}.py").write_text( + "def build_adapter(context):\n" + " def refuse(**_kwargs):\n" + " raise AssertionError('probe ports must not be invoked')\n" + f" return {{{REQUEST_BIND_PORT!r}: refuse, {REQUEST_SETTLE_PORT!r}: refuse}}\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(package_root)) + monkeypatch.delitem(sys.modules, _PROBE_FACTORY_MODULE, raising=False) + provider = tmp_path / "probe-provider" + provider.write_text(f"#!{sys.executable}\nraise SystemExit(0)\n", encoding="utf-8") + provider.chmod(0o755) + manifest = tmp_path / "extension.toml" + adapter = """ +[[hook_adapters]] +id = "{adapter_id}" +capability_id = "{capability_id}" +target_hook_id = "{hook_id}" +phase = "{phase}" +factory = "{module}:build_adapter" +required_permissions = ["probe.source.read"] +ports = [{ports}] +""" + manifest.write_text( + f"""\ +schema_version = "loopx_extension_manifest_v0" +id = "probe-extension" +version = "1.0.0" +requires_loopx_api = ">=1,<2" +permissions = ["probe.source.read"] + +[runtime] +protocol = "probe_extension_lifecycle_v0" +entrypoint = {json.dumps(str(provider))} +doctor_args = ["--doctor"] +required_permissions = [] +timeout_seconds = 5 +""" + + adapter.format( + adapter_id="probe-source", + capability_id="periodic-report", + hook_id=REQUEST_HOOK_ID, + phase=REQUEST_ADAPTER_PHASE, + module=_PROBE_FACTORY_MODULE, + ports=f'"{REQUEST_BIND_PORT}", "{REQUEST_SETTLE_PORT}"', + ) + # Same extension, another capability's hook: outside this request scope. + + adapter.format( + adapter_id="other-hook-source", + capability_id="sample-report", + hook_id="sample_report.request", + phase=REQUEST_ADAPTER_PHASE, + module=_PROBE_FACTORY_MODULE, + ports='"sample_report.request.bind_source"', + ), + encoding="utf-8", + ) + state_file = tmp_path / "runtime" / "extensions" / "state.json" + installed = install_extension(manifest, state_file=state_file, execute=True) + assert installed["doctor"]["verified"] is True + return provider, state_file + + +def _tree_digest(root: Path, *, exclude: Path) -> dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sorted(root.rglob("*")) + if path.is_file() and exclude not in path.parents + } + + +def _request_cli( + capsys: pytest.CaptureFixture[str], + *, + registry: Path, + runtime_root: Path, + selector: str | None, +) -> tuple[int, dict[str, object]]: + from loopx.cli import main + + code = main( + [ + "--registry", + str(registry), + "--runtime-root", + str(runtime_root), + "--format", + "json", + "periodic-report", + "request", + "--goal-id", + "goal-alpha", + "--agent-id", + "agent-alpha", + "--source-ref", + "om_probe_request", + *(["--source-adapter-id", selector] if selector else []), + "--execute", + ] + ) + return code, json.loads(capsys.readouterr().out) + + +def test_request_cli_without_installed_extensions_keeps_the_plain_refusal( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": []}), encoding="utf-8") + for selector, suffix in ((None, ""), ("never-declared", ": never-declared")): + code, payload = _request_cli( + capsys, + registry=registry, + runtime_root=tmp_path / "runtime", + selector=selector, + ) + assert (code, payload["ok"]) == (1, False) + assert payload["error"] == ( + "periodic-report request source adapter is unavailable" + suffix + ) + assert sorted(path.name for path in tmp_path.iterdir()) == ["registry.json"] + + +@pytest.mark.parametrize( + ("lifecycle", "expected_code"), + [ + ("disabled", "extension_disabled"), + ("doctor_stale", "extension_doctor_not_ready"), + ("state_unreadable", "extension_state_unavailable"), + ], +) +def test_request_cli_names_real_lifecycle_loss_for_every_selector( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + lifecycle: str, + expected_code: str, +) -> None: + provider, state_file = _install_probe_source_extension(tmp_path, monkeypatch) + runtime_root = state_file.parent.parent + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": []}), encoding="utf-8") + + # Positive control: while ready, the probe is one complete adapter and its + # factory is imported, so "not imported" below is not vacuous. + ready = discover_periodic_report_request_ports( + registry_path=registry, + runtime_root=runtime_root, + goal_id="goal-alpha", + agent_id="agent-alpha", + ) + assert ready.adapter_ids == ("probe-source",) + assert ready.failed_adapters == {} + assert _PROBE_FACTORY_MODULE in sys.modules + del sys.modules[_PROBE_FACTORY_MODULE] + + if lifecycle == "disabled": + disable_extension("probe-extension", state_file=state_file, execute=True) + elif lifecycle == "doctor_stale": + # Changing the doctor-verified executable invalidates its identity. + provider.write_text( + provider.read_text(encoding="utf-8") + "# changed\n", encoding="utf-8" + ) + else: + state_file.write_text("{not json", encoding="utf-8") + before = _tree_digest(tmp_path, exclude=tmp_path / "probe-package") + + for selector in (None, "probe-source", "never-declared"): + code, payload = _request_cli( + capsys, registry=registry, runtime_root=runtime_root, selector=selector + ) + error = str(payload["error"]) + assert (code, payload["ok"]) == (1, False), (selector, payload) + assert error.startswith("periodic-report request source adapter is unavailable") + if selector: + assert selector in error + if lifecycle == "state_unreadable": + # Unreadable state hides every declaration, known or not. + assert f"extension state could not be read ({expected_code})" in error + elif selector == "never-declared": + assert "probe-source" not in error and expected_code not in error + else: + assert "probe-source" in error and f"({expected_code})" in error + assert "loopx extension doctor" in error + assert "other-hook-source" not in error + assert _PROBE_FACTORY_MODULE not in sys.modules + assert _tree_digest(tmp_path, exclude=tmp_path / "probe-package") == before + assert not (runtime_root / "goals").exists() + + def test_lark_inbox_rejects_same_name_mention_with_different_provider_identity() -> ( None ):