fix(plugins): implement EXECUTING state in PluginManager lifecycle

- Added execute_plugin() method to PluginManager that properly sets PluginState.EXECUTING before calling a plugin method

- Transitions state back to ACTIVATED on success, or ERRORED on failure

- Completes the lifecycle state machine defined in PluginState enum

- Added BDD feature file features/plugin_executing_state.feature with scenarios covering state transitions

- Added step definitions in features/steps/plugin_executing_state_steps.py

ISSUES CLOSED: #5691
This commit is contained in:
2026-04-19 00:05:55 +00:00
committed by Forgejo
parent 24d296820f
commit b0edcb53a4
3 changed files with 300 additions and 0 deletions
+99
View File
@@ -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
@@ -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)
@@ -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
# ------------------------------------------------------------------