Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion loopx/capabilities/periodic_report/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,16 @@ automatically. With multiple active providers, the Agent selects the provider
explicitly with `--source-adapter-id <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
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
Expand Down
63 changes: 58 additions & 5 deletions loopx/capabilities/periodic_report/request_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,11 +72,44 @@ class PeriodicReportRequestAdapter:
@dataclass(frozen=True, slots=True)
class PeriodicReportRequestPorts:
adapters: Mapping[str, PeriodicReportRequestAdapter]
# 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

@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 was not activated "
f"({self.failed_adapters[selected_id]}); inspect "
"`loopx extension doctor <extension-id> --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.failed_adapters:
failed = ", ".join(
f"{adapter_id} ({code})"
for adapter_id, code in sorted(self.failed_adapters.items())
)
return (
f": declared adapters were not activated: {failed}; inspect "
"`loopx extension doctor <extension-id> --execute`"
)
return ""

def select_source_adapter(
self, adapter_id: str | None
) -> PeriodicReportRequestAdapter:
Expand All @@ -85,14 +118,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"
Expand Down Expand Up @@ -187,7 +223,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(
Expand Down
74 changes: 63 additions & 11 deletions loopx/extensions/hook_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -27,22 +33,37 @@ 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)
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
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.
"""

ports: tuple[ExtensionHookAdapterPortBinding, ...]
failures: tuple[ExtensionHookAdapterFailure, ...]
state_error_code: str | None = None


def _load_factory(reference: str) -> Callable[..., Mapping[str, Callable[..., Any]]]:
Expand All @@ -56,17 +77,31 @@ 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,
adapter_id=str(adapter["id"]),
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,
Expand All @@ -78,28 +113,36 @@ 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.
"""

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] = []
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)
Expand All @@ -108,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,
Expand Down
9 changes: 7 additions & 2 deletions loopx/extensions/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading