fix(cli): share PlanLifecycleService instance between CLI handler and PlanExecutor #1027

Merged
CoreRasurae merged 1 commits from test/e2e-m6-acceptance into master 2026-03-17 12:29:39 +00:00
7 changed files with 180 additions and 4 deletions
+18
View File
@@ -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
+28
View File
@@ -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
+17 -1
View File
@@ -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.
+45
View File
@@ -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."
)
+13 -3
View File
@@ -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