fix(arch-014,arch-020): no contract lazy-resurrect after unregister; event bus dedupes handlers
Check Cross-Plugin Imports / check (push) Has been cancelled

Also fixes ARCH-029/041: none-check before attribute access in trigger dispatcher.
This commit is contained in:
Agent Zero
2026-08-23 15:30:12 +02:00
parent 982b4c9353
commit b04cda774b
3 changed files with 29 additions and 5 deletions
+7 -2
View File
@@ -36,8 +36,13 @@ class EventBus:
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_name: str, handler: EventHandler) -> None:
"""Subscribe a handler to an event."""
self._handlers[event_name].append(handler)
"""Subscribe a handler to an event.
Idempotent: subscribing the same handler twice is a no-op
(ARCH-020) so double activation cannot fire handlers twice.
"""
if handler not in self._handlers[event_name]:
self._handlers[event_name].append(handler)
def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
"""Unsubscribe a handler from an event."""
+4 -1
View File
@@ -121,11 +121,14 @@ class TriggerDispatcher:
"""Query DB for active automations matching *event_name* and dispatch."""
from app.core.db import get_session_factory
from app.plugins.builtins.contracts import get_contract
# None-check FIRST — accessing attributes on the contract before the
# check crashed with AttributeError when automation was inactive
# (ARCH-029/041).
automation_contract = get_contract("automation")
AutomationDefinition = automation_contract.Automation # noqa: N806
if automation_contract is None:
logger.debug("Automation plugin not available — trigger skipped")
return
AutomationDefinition = automation_contract.Automation # noqa: N806
factory = get_session_factory()
tenant_id = payload.get("tenant_id")
+18 -2
View File
@@ -59,20 +59,31 @@ class ContractRegistry:
cls._instance = super().__new__(cls)
cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set()
cls._instance._unregistered: set[str] = set()
return cls._instance
# ─── registration ───
def register(self, plugin_name: str, contract: Any) -> None:
"""Register or replace a contract for a plugin."""
"""Register or replace a contract for a plugin.
Clears the unregistered marker so a later deactivation can be
distinguished from a fresh lazy-load again (ARCH-014).
"""
self._unregistered.discard(plugin_name)
self._contracts[plugin_name] = contract
self._loaded.add(plugin_name)
logger.debug("Contract registered for plugin '%s'", plugin_name)
def unregister(self, plugin_name: str) -> None:
"""Remove a contract (e.g. when the plugin is deactivated)."""
"""Remove a contract (e.g. when the plugin is deactivated).
Marks the plugin as explicitly unregistered so later ``get_contract``
calls cannot resurrect the contract via lazy-loading (ARCH-014).
"""
self._contracts.pop(plugin_name, None)
self._loaded.discard(plugin_name)
self._unregistered.add(plugin_name)
# ─── lookup ───
@@ -85,6 +96,11 @@ class ContractRegistry:
if plugin_name in self._contracts:
return self._contracts[plugin_name]
# Explicitly unregistered (deactivated): never resurrect via
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
if plugin_name in self._unregistered:
return None
if plugin_name not in self._loaded:
self._try_lazy_load(plugin_name)