fix(cli): share PlanLifecycleService instance between CLI handler and PlanExecutor #1027
@@ -2,6 +2,24 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed `plan execute` CLI failing with "Plan is not in an executable state
|
||||
(current: strategize/queued)" after strategize completed successfully.
|
||||
Root cause: `_get_plan_executor()` created a second `PlanLifecycleService`
|
||||
Factory instance with its own in-memory `_plans` cache. After the executor's
|
||||
`run_strategize()` advanced the plan to `execute/queued` (via `auto_progress`),
|
||||
the CLI handler's separate service instance returned stale `strategize/queued`
|
||||
state from its cache. Fix: `_get_plan_executor()` now accepts an optional
|
||||
`lifecycle_service` parameter; the `plan execute` handler passes its own
|
||||
service instance so both share the same cache.
|
||||
(`src/cleveragents/cli/commands/plan.py`)
|
||||
- Improved type safety: `_get_plan_executor()` parameter
|
||||
`lifecycle_service` now typed as `PlanLifecycleService | None` instead
|
||||
of `Any | None`.
|
||||
(`src/cleveragents/cli/commands/plan.py`)
|
||||
- Added BDD regression test verifying that `plan execute` CLI handler
|
||||
passes its lifecycle service instance to `_get_plan_executor()`,
|
||||
preventing stale-cache regressions.
|
||||
(`features/plan_lifecycle_cli_coverage.feature`)
|
||||
- Added four CLI-based integration test cases to M5 E2E verification suite
|
||||
for v3.4.0 milestone acceptance criteria validation. Tests exercise
|
||||
`project create`, `resource add git-checkout`, `project link-resource`, and
|
||||
|
||||
@@ -80,6 +80,34 @@ records decisions during phase transitions:
|
||||
If no `DecisionService` is provided (e.g. in legacy tests), decision
|
||||
recording is silently skipped.
|
||||
|
||||
### Cache-Sharing Caveat
|
||||
|
||||
Because `plan_lifecycle_service` is a **Factory**, each
|
||||
`container.plan_lifecycle_service()` call returns a **new instance**
|
||||
with its own in-memory `_plans` cache. Code that creates a
|
||||
`PlanLifecycleService` and separately constructs a `PlanExecutor`
|
||||
**must** pass the same service instance to both; otherwise the
|
||||
executor's phase mutations (e.g. `complete_strategize` →
|
||||
`auto_progress` → `execute/queued`) will not be visible to the
|
||||
caller's service instance, producing stale state reads.
|
||||
|
||||
```python
|
||||
# Correct: shared instance
|
||||
service = container.plan_lifecycle_service()
|
||||
executor = PlanExecutor(lifecycle_service=service, ...)
|
||||
|
||||
# Wrong: two independent instances with separate caches
|
||||
service = container.plan_lifecycle_service() # Instance A
|
||||
executor = PlanExecutor(
|
||||
lifecycle_service=container.plan_lifecycle_service(), # Instance B
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
The CLI helper `_get_plan_executor(lifecycle_service=...)` accepts an
|
||||
optional pre-existing service for exactly this purpose. See
|
||||
[CLI Executor Wiring](plan_execute.md#cli-executor-wiring-_get_plan_executor).
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
|
||||
@@ -123,12 +123,28 @@ when present on the plan.
|
||||
|
||||
## `agents plan execute`
|
||||
|
||||
Transition a plan from Strategize to Execute phase.
|
||||
Run the current plan phase synchronously. Detects the plan's current
|
||||
phase and processes it inline:
|
||||
|
||||
- **Strategize/queued** — runs the strategize phase to completion, then
|
||||
auto-progresses to Execute if the automation profile permits.
|
||||
- **Strategize/complete** — transitions to Execute and runs it.
|
||||
- **Execute/queued** — runs the execute phase to completion.
|
||||
|
||||
When no plan ID is given, auto-selects the single eligible plan.
|
||||
|
||||
```bash
|
||||
agents plan execute [PLAN_ID] [--format FORMAT]
|
||||
```
|
||||
|
||||
### Internal Wiring
|
||||
|
||||
The CLI handler shares a single `PlanLifecycleService` instance between
|
||||
the command logic and the `PlanExecutor` to avoid stale in-memory cache
|
||||
reads after phase transitions. See
|
||||
[CLI Executor Wiring](plan_execute.md#cli-executor-wiring-_get_plan_executor)
|
||||
for details.
|
||||
|
||||
## `agents plan lifecycle-apply`
|
||||
|
||||
Transition a plan from Execute to Apply phase.
|
||||
|
||||
@@ -125,6 +125,51 @@ assert executor.changeset_store is not None
|
||||
result = executor.run_execute(plan_id)
|
||||
```
|
||||
|
||||
## CLI Executor Wiring (`_get_plan_executor`)
|
||||
|
||||
The `plan execute` CLI command constructs a `PlanExecutor` via the
|
||||
internal `_get_plan_executor()` helper in
|
||||
`cleveragents.cli.commands.plan`. This helper resolves the
|
||||
`ProviderRegistry` from the DI container and builds
|
||||
`LLMStrategizeActor` / `LLMExecuteActor` for real LLM calls.
|
||||
|
||||
```python
|
||||
def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None):
|
||||
"""Build a PlanExecutor wired with real LLM actors.
|
||||
|
||||
Args:
|
||||
lifecycle_service: Optional pre-existing PlanLifecycleService.
|
||||
When provided the executor shares the same service (and its
|
||||
in-memory plan cache) as the caller.
|
||||
"""
|
||||
```
|
||||
|
||||
### Shared Lifecycle Service Instance
|
||||
|
||||
Because `PlanLifecycleService` is registered as a **Factory** provider
|
||||
in the DI container, each `container.plan_lifecycle_service()` call
|
||||
returns a **new instance** with its own in-memory `_plans` cache. The
|
||||
`plan execute` handler creates one service for CLI-level state reads
|
||||
and passes it to `_get_plan_executor()` so the executor shares the
|
||||
same instance:
|
||||
|
||||
```python
|
||||
# In the plan execute CLI handler:
|
||||
service = _get_lifecycle_service()
|
||||
executor = _get_plan_executor(lifecycle_service=service) # shared instance
|
||||
```
|
||||
|
||||
This is critical because `executor.run_strategize()` mutates plan
|
||||
state through the lifecycle service (e.g. advancing from
|
||||
`strategize/complete` to `execute/queued` via `auto_progress`). If
|
||||
the executor used a separate service instance, the CLI handler's
|
||||
subsequent `service.get_plan()` call would return stale cached state,
|
||||
causing spurious "not in an executable state" errors.
|
||||
|
||||
> **Note:** When `lifecycle_service` is omitted (e.g. from test code
|
||||
> or standalone scripts), `_get_plan_executor` falls back to creating
|
||||
> a fresh instance from the container.
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|
||||
@@ -158,3 +158,7 @@ Feature: Plan lifecycle CLI coverage
|
||||
When I run plan cancel for plan id "01ARZ3NDEKTSV4RRFFQ69G5FB5" causing "general error"
|
||||
Then the plan lifecycle command should abort
|
||||
And the plan lifecycle output should contain "Error"
|
||||
|
||||
Scenario: Plan execute shares lifecycle service instance with executor
|
||||
When I run plan execute verifying lifecycle service sharing
|
||||
Then the plan executor should receive the same lifecycle service instance
|
||||
|
||||
@@ -512,3 +512,58 @@ def step_lifecycle_apply_runs_single_plan(context) -> None:
|
||||
assert len(context.apply_plans) == 1
|
||||
plan_id = context.apply_plans[0].identity.plan_id
|
||||
context.lifecycle_service.apply_plan.assert_called_once_with(plan_id)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Regression: lifecycle-service sharing between CLI handler & executor
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run plan execute verifying lifecycle service sharing")
|
||||
def step_plan_execute_verify_sharing(context) -> None:
|
||||
"""Run ``plan execute`` and capture the ``_get_plan_executor`` call.
|
||||
|
||||
After Background has already mocked ``_get_lifecycle_service`` to
|
||||
return ``context.lifecycle_service``, we patch ``_get_plan_executor``
|
||||
so we can later assert it was called with the **same** service
|
||||
instance.
|
||||
"""
|
||||
plan = _make_plan(
|
||||
plan_id=_ULIDS[0],
|
||||
name="local/shared-svc-plan",
|
||||
phase=PlanPhase.STRATEGIZE,
|
||||
processing_state=ProcessingState.COMPLETE,
|
||||
)
|
||||
context.lifecycle_service.list_plans.return_value = [plan]
|
||||
context.lifecycle_service.get_plan.return_value = plan
|
||||
context.lifecycle_service.execute_plan.return_value = plan
|
||||
|
||||
mock_executor = MagicMock()
|
||||
executor_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||
return_value=mock_executor,
|
||||
)
|
||||
mock_get_executor = executor_patcher.start()
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(executor_patcher.stop)
|
||||
context._mock_get_plan_executor = mock_get_executor
|
||||
|
||||
context.result = context.runner.invoke(plan_app, ["execute"])
|
||||
|
||||
|
||||
@then("the plan executor should receive the same lifecycle service instance")
|
||||
def step_executor_shares_lifecycle_service(context) -> None:
|
||||
"""Assert ``_get_plan_executor`` was called with the shared service."""
|
||||
mock_fn = context._mock_get_plan_executor
|
||||
mock_fn.assert_called_once()
|
||||
call_kwargs = mock_fn.call_args.kwargs
|
||||
assert "lifecycle_service" in call_kwargs, (
|
||||
"_get_plan_executor was not called with a lifecycle_service kwarg; "
|
||||
f"got kwargs: {call_kwargs}"
|
||||
)
|
||||
assert call_kwargs["lifecycle_service"] is context.lifecycle_service, (
|
||||
"Expected _get_plan_executor to receive the same lifecycle service "
|
||||
"instance that _get_lifecycle_service() returned, but it received "
|
||||
"a different object."
|
||||
)
|
||||
|
||||
@@ -81,6 +81,9 @@ _LEGACY_DEPRECATION_MSG = (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.domain.models.core import Change, Plan, Project
|
||||
from cleveragents.domain.models.core.decision import Decision
|
||||
|
||||
@@ -1201,13 +1204,19 @@ def _get_lifecycle_service():
|
||||
return container.plan_lifecycle_service()
|
||||
|
||||
|
||||
def _get_plan_executor() -> Any:
|
||||
def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) -> Any:
|
||||
"""Build a ``PlanExecutor`` wired with real LLM actors.
|
||||
|
||||
Resolves the ``ProviderRegistry`` and ``PlanLifecycleService`` from
|
||||
the DI container and constructs ``LLMStrategizeActor`` /
|
||||
``LLMExecuteActor`` so that ``plan execute`` invocations drive real
|
||||
LLM calls instead of the local-only stub actors.
|
||||
|
||||
Args:
|
||||
lifecycle_service: Optional pre-existing ``PlanLifecycleService``
|
||||
instance. When provided the executor shares the same service
|
||||
(and its in-memory cache) as the caller, avoiding stale-read
|
||||
bugs caused by the DI ``Factory`` creating independent instances.
|
||||
"""
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.application.services.llm_actors import (
|
||||
@@ -1218,7 +1227,8 @@ def _get_plan_executor() -> Any:
|
||||
|
||||
container = get_container()
|
||||
registry = container.provider_registry()
|
||||
lifecycle_service = _get_lifecycle_service()
|
||||
if lifecycle_service is None:
|
||||
lifecycle_service = _get_lifecycle_service()
|
||||
|
||||
strategize_actor = LLMStrategizeActor(
|
||||
provider_registry=registry,
|
||||
@@ -1683,7 +1693,7 @@ def execute_plan(
|
||||
)
|
||||
|
||||
service = _get_lifecycle_service()
|
||||
executor = _get_plan_executor()
|
||||
executor = _get_plan_executor(lifecycle_service=service)
|
||||
|
||||
if not plan_id:
|
||||
# Auto-discover: look for plans in strategize or execute
|
||||
|
||||
Reference in New Issue
Block a user