diff --git a/features/plugin_executing_state.feature b/features/plugin_executing_state.feature new file mode 100644 index 000000000..14575785c --- /dev/null +++ b/features/plugin_executing_state.feature @@ -0,0 +1,99 @@ +@mock_only +Feature: PluginState.EXECUTING lifecycle state + The PluginManager must set the EXECUTING state when a plugin method is + being invoked, completing the plugin lifecycle state machine as defined + in the specification. + + Based on issue #5691. + + # ----------------------------------------------------------------------- + # Happy path: execute_plugin transitions ACTIVATED -> EXECUTING -> ACTIVATED + # ----------------------------------------------------------------------- + + Scenario: execute_plugin transitions plugin to ACTIVATED after successful execution + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "exec-plugin" + And I execute method "search" on plugin "exec-plugin" with valid args + Then the plugin "exec-plugin" state should be "activated" + + Scenario: execute_plugin returns the method result + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "exec-plugin" + And I execute method "search" on plugin "exec-plugin" with valid args + Then the execution result should be an empty list + + # ----------------------------------------------------------------------- + # Error path: execute_plugin transitions ACTIVATED -> EXECUTING -> ERRORED + # ----------------------------------------------------------------------- + + Scenario: execute_plugin transitions plugin to ERRORED on method exception + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "exec-plugin" + And I attempt to execute a failing method on plugin "exec-plugin" + Then the plugin "exec-plugin" state should be "errored" + And a PluginError should be raised + + Scenario: execute_plugin wraps method exceptions in PluginError + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "exec-plugin" + And I attempt to execute a failing method on plugin "exec-plugin" + Then a PluginError should be raised + And the plugin error message should contain "execution failed" + + # ----------------------------------------------------------------------- + # Guard: execute_plugin requires ACTIVATED state + # ----------------------------------------------------------------------- + + Scenario: execute_plugin raises PluginError if plugin is not activated + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I attempt to execute method on non-activated plugin "exec-plugin" + Then a PluginError should be raised + + Scenario: execute_plugin raises PluginError if plugin is in ERRORED state + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "exec-plugin" + And I attempt to execute a failing method on plugin "exec-plugin" + And I attempt to execute method on non-activated plugin "exec-plugin" + Then a PluginError should be raised + + # ----------------------------------------------------------------------- + # Thread safety: EXECUTING state prevents concurrent deactivation + # ----------------------------------------------------------------------- + + Scenario: Cannot deactivate a plugin that is in EXECUTING state + Given a fresh PluginManager instance + And a PluginDescriptor for "exec-plugin" with module "cleveragents.domain.models.acms.stubs" and class "InMemoryTextBackend" + When I register the plugin descriptor + And I activate the plugin "exec-plugin" + And I manually set plugin "exec-plugin" state to "executing" + And I attempt to deactivate the plugin "exec-plugin" + Then a PluginError should be raised + + # ----------------------------------------------------------------------- + # PluginState enum includes EXECUTING + # ----------------------------------------------------------------------- + + Scenario: PluginState enum includes the EXECUTING value + Given the PluginState enum is available + Then it should have values "discovered", "activated", "executing", "deactivated", "errored" + + # ----------------------------------------------------------------------- + # execute_plugin raises PluginNotFoundError for unknown plugin + # ----------------------------------------------------------------------- + + Scenario: execute_plugin raises PluginNotFoundError for unknown plugin + Given a fresh PluginManager instance + And I attempt to execute method on unknown plugin "nonexistent-plugin" + Then a PluginNotFoundError should be raised diff --git a/features/steps/plugin_executing_state_steps.py b/features/steps/plugin_executing_state_steps.py new file mode 100644 index 000000000..b68066f72 --- /dev/null +++ b/features/steps/plugin_executing_state_steps.py @@ -0,0 +1,136 @@ +"""Behave step definitions for PluginState.EXECUTING lifecycle state. + +Covers the execute_plugin() method on PluginManager, which transitions +plugins through ACTIVATED -> EXECUTING -> ACTIVATED (success) or +ACTIVATED -> EXECUTING -> ERRORED (failure). + +Based on issue #5691. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from behave import given, then, when # type: ignore[import-untyped] +from behave.runner import Context # type: ignore[import-untyped] + +from cleveragents.infrastructure.plugins.exceptions import ( + PluginError, + PluginNotFoundError, +) +from cleveragents.infrastructure.plugins.manager import PluginManager +from cleveragents.infrastructure.plugins.types import ( + PluginDescriptor, + PluginState, +) + + +# --------------------------------------------------------------------------- +# Execute plugin — happy path +# --------------------------------------------------------------------------- + + +@when('I execute method "search" on plugin "{name}" with valid args') +def step_execute_plugin_search(context: Context, name: str) -> None: + """Execute the search method on an InMemoryTextBackend plugin.""" + context.caught_exception = None + context.execution_result = None + try: + context.execution_result = context.manager.execute_plugin( + name, + "search", + "test query", + scope=frozenset(), + max_results=5, + ) + except PluginError as exc: + context.caught_exception = exc + + +@then("the execution result should be an empty list") +def step_execution_result_empty_list(context: Context) -> None: + assert context.execution_result == [], ( + f"Expected empty list, got {context.execution_result!r}" + ) + + +# --------------------------------------------------------------------------- +# Execute plugin — error path +# --------------------------------------------------------------------------- + + +@when('I attempt to execute a failing method on plugin "{name}"') +def step_attempt_execute_failing(context: Context, name: str) -> None: + """Execute a method that raises an exception on the plugin.""" + context.caught_exception = None + # Patch the instance's search method to raise a RuntimeError + instance = context.manager.get_plugin_instance(name) + if instance is not None: + with patch.object(instance, "search", side_effect=RuntimeError("boom")): + try: + context.manager.execute_plugin( + name, + "search", + "test query", + scope=frozenset(), + max_results=5, + ) + except PluginError as exc: + context.caught_exception = exc + else: + # Plugin not activated — try to execute anyway to get the error + try: + context.manager.execute_plugin(name, "search", "test", scope=frozenset()) + except PluginError as exc: + context.caught_exception = exc + + +# --------------------------------------------------------------------------- +# Execute plugin — guard: not activated +# --------------------------------------------------------------------------- + + +@when('I attempt to execute method on non-activated plugin "{name}"') +def step_attempt_execute_non_activated(context: Context, name: str) -> None: + """Attempt to execute a method on a plugin that is not in ACTIVATED state.""" + context.caught_exception = None + try: + context.manager.execute_plugin( + name, + "search", + "test query", + scope=frozenset(), + max_results=5, + ) + except PluginError as exc: + context.caught_exception = exc + + +@when('I attempt to execute method on unknown plugin "{name}"') +def step_attempt_execute_unknown(context: Context, name: str) -> None: + """Attempt to execute a method on a plugin that is not registered.""" + context.caught_exception = None + try: + context.manager.execute_plugin( + name, + "search", + "test query", + scope=frozenset(), + max_results=5, + ) + except (PluginError, PluginNotFoundError) as exc: + context.caught_exception = exc + + + +# --------------------------------------------------------------------------- +# Thread safety: EXECUTING state prevents deactivation +# --------------------------------------------------------------------------- + + +@when('I manually set plugin "{name}" state to "{state}"') +def step_manually_set_state(context: Context, name: str, state: str) -> None: + """Manually set a plugin's state for testing purposes.""" + descriptor = context.manager.get_plugin(name) + descriptor.state = PluginState(state) diff --git a/src/cleveragents/infrastructure/plugins/manager.py b/src/cleveragents/infrastructure/plugins/manager.py index 444a3b157..63dd36a37 100644 --- a/src/cleveragents/infrastructure/plugins/manager.py +++ b/src/cleveragents/infrastructure/plugins/manager.py @@ -53,6 +53,7 @@ class PluginManager: ) manager.register_plugin(descriptor) manager.activate_plugin("my-plugin") + manager.execute_plugin("my-plugin", "my_method", arg1, kwarg=val) info = manager.get_plugin("my-plugin") manager.deactivate_plugin("my-plugin") @@ -272,6 +273,70 @@ class PluginManager: msg = f"Failed to activate plugin '{name}': {exc}" raise PluginError(msg) from exc + # ------------------------------------------------------------------ + # Lifecycle: execute + # ------------------------------------------------------------------ + + def execute_plugin(self, name: str, method: str, *args: Any, **kwargs: Any) -> Any: + """Execute a method on an activated plugin. + + Transitions the plugin to ``EXECUTING`` state before calling the + method, then back to ``ACTIVATED`` on success, or ``ERRORED`` on + exception. The lock is released during execution so other threads + can query the registry while the plugin is running. + + Args: + name: Plugin name. + method: Name of the method to call on the plugin instance. + *args: Positional arguments forwarded to the method. + **kwargs: Keyword arguments forwarded to the method. + + Returns: + The return value of the plugin method. + + Raises: + PluginNotFoundError: If the plugin is not registered. + PluginError: If the plugin is not in ``ACTIVATED`` state, or + if the method call raises any exception. + """ + with self._lock: + descriptor = self.get_plugin(name) + if descriptor.state != PluginState.ACTIVATED: + msg = ( + f"Plugin '{name}' is not in ACTIVATED state " + f"(current: '{descriptor.state}')" + ) + raise PluginError(msg) + descriptor.state = PluginState.EXECUTING + self._logger.debug( + "plugin_manager.executing", + name=name, + method=method, + ) + + try: + instance = self._instances[name] + result = getattr(instance, method)(*args, **kwargs) + with self._lock: + descriptor.state = PluginState.ACTIVATED + self._logger.debug( + "plugin_manager.executed", + name=name, + method=method, + ) + return result + except Exception as exc: + with self._lock: + descriptor.state = PluginState.ERRORED + self._logger.warning( + "plugin_manager.execution_failed", + name=name, + method=method, + error=str(exc), + ) + msg = f"Plugin '{name}' execution failed: {exc}" + raise PluginError(msg) from exc + # ------------------------------------------------------------------ # Lifecycle: deactivate # ------------------------------------------------------------------