fix: wire DI persistence and plan execute/apply for M1 lifecycle
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 42s
CI / security (pull_request) Successful in 46s
CI / quality (pull_request) Successful in 27s
CI / unit_tests (pull_request) Successful in 3m16s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 15s
CI / e2e_tests (pull_request) Successful in 1m12s
CI / integration_tests (pull_request) Successful in 4m20s
CI / docker (pull_request) Successful in 59s
CI / coverage (pull_request) Successful in 6m34s
CI / lint (push) Successful in 20s
CI / typecheck (push) Successful in 45s
CI / security (push) Successful in 46s
CI / quality (push) Successful in 37s
CI / build (push) Successful in 17s
CI / e2e_tests (push) Successful in 52s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 5m9s
CI / integration_tests (push) Successful in 5m32s
CI / docker (push) Successful in 57s
CI / coverage (push) Successful in 6m10s
CI / benchmark-publish (push) Successful in 20m11s
CI / benchmark-regression (pull_request) Successful in 38m29s

Fixed 5 bugs preventing the M1 E2E acceptance test from passing:

1. _get_lifecycle_service() in action.py and plan.py bypassed the DI
   container, creating PlanLifecycleService without UnitOfWork. All
   plan/action data was in-memory only and lost between subprocess
   calls. Now uses container.plan_lifecycle_service() for DB persistence.

2. `plan execute` CLI only called service.execute_plan() (a pure state
   transition) without running PlanExecutor phase processing. Rewrote
   to detect the plan's current phase/state and dispatch synchronously:
   Strategize/queued → run_strategize(), Strategize/complete → transition
   + run_execute(), Execute/queued → run_execute().

3. `plan apply` CLI had no plan_id argument. Added optional positional
   plan_id with _lifecycle_apply_with_id() that drives the plan through
   Apply/queued → Apply/processing → Apply/applied.

4. Preflight guardrail in start_strategize() built action_registry from
   the in-memory _actions dict only. Added get_action(plan.action_name)
   call to load the action from DB into cache before the guardrail check.

5. Robot Framework Create File syntax used continuation lines producing
   9 arguments instead of 1. Fixed to use Catenate SEPARATOR=\n then
   pass single variable to Create File. Also fixed --branch main to
   --branch master (git init default).

update mocks for execute_plan CLI changes across unit and integration tests

The new execute_plan() command calls _get_plan_executor() and
service.get_plan(plan_id) for phase/state detection. Existing tests
only mocked _get_lifecycle_service, so MagicMock defaults caused
phase/state comparisons to fail.

Changes across 14 files:
- Patch _get_plan_executor in all test setups that invoke the CLI
  execute command (Behave step files + Robot helper scripts)
- Set service.get_plan.return_value to real Plan objects with correct
  phase/state so the execute_plan dispatch logic works
- Fix error-path tests to use STRATEGIZE/COMPLETE plans so the error
  side_effects are actually reached
- Fix "Multiple plans eligible" → "Multiple plans ready" message text
  to match existing test expectations

increase Robot Framework subprocess timeouts for CI resource contention

Three integration tests were timing out in CI due to resource contention
when pabot runs multiple test suites in parallel. All three pass locally
and the timeouts were simply too tight for constrained CI environments.

- tdd_session_create_di.robot: 30s → 90s (DI container init + DB setup)
- database_integration.robot: 60s → 120s (Run Python Script keyword)
- m3_e2e_verification.robot: 60s → 120s (correction-live-revert spawns
  3 sequential CLI subprocesses with full container initialization)

ISSUES CLOSED: #789
This commit was merged in pull request #957.
This commit is contained in:
2026-03-15 00:30:39 +00:00
committed by Forgejo
parent cb3b7aab44
commit 5f07316641
20 changed files with 417 additions and 75 deletions
+17 -3
View File
@@ -158,13 +158,19 @@ def step_lc_mocked_service(context: Context) -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.lc_mock,
)
context.lc_executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
context.lc_action_patcher.start()
context.lc_plan_patcher.start()
context.lc_executor_patcher.start()
if not hasattr(context, "_lc_cleanup"):
context._lc_cleanup = []
context._lc_cleanup.append(context.lc_action_patcher.stop)
context._lc_cleanup.append(context.lc_plan_patcher.stop)
context._lc_cleanup.append(context.lc_executor_patcher.stop)
# ---------------------------------------------------------------------------
@@ -824,6 +830,7 @@ def step_lc_plan_terminal(context: Context) -> None:
def step_lc_plan_ready_execute(context: Context) -> None:
plan = _make_lc_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
context.lc_mock.execute_plan.return_value = plan
context.lc_mock.get_plan.return_value = plan
context.lc_plan = plan
@@ -834,6 +841,7 @@ def step_lc_single_execute(context: Context) -> None:
state=ProcessingState.COMPLETE,
)
context.lc_mock.list_plans.return_value = [plan]
context.lc_mock.get_plan.return_value = plan
executed = _make_lc_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
context.lc_mock.execute_plan.return_value = executed
context.lc_plan = plan
@@ -865,24 +873,30 @@ def step_lc_multi_execute(context: Context) -> None:
@given("lifecycle coverage a plan with invalid transition for execute")
def step_lc_invalid_transition(context: Context) -> None:
plan = _make_lc_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE)
context.lc_mock.execute_plan.side_effect = InvalidPhaseTransitionError(
PlanPhase.APPLY, PlanPhase.EXECUTE
)
context.lc_plan = _make_lc_plan()
context.lc_mock.get_plan.return_value = plan
context.lc_plan = plan
@given("lifecycle coverage a plan not ready for execute")
def step_lc_plan_not_ready(context: Context) -> None:
plan = _make_lc_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE)
context.lc_mock.execute_plan.side_effect = PlanNotReadyError(
_PLAN_ULID, PlanPhase.STRATEGIZE, ProcessingState.QUEUED
)
context.lc_plan = _make_lc_plan()
context.lc_mock.get_plan.return_value = plan
context.lc_plan = plan
@given("lifecycle coverage a plan execute with general error")
def step_lc_plan_exec_general_error(context: Context) -> None:
plan = _make_lc_plan(phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE)
context.lc_mock.execute_plan.side_effect = CleverAgentsError("Execute failed")
context.lc_plan = _make_lc_plan()
context.lc_mock.get_plan.return_value = plan
context.lc_plan = plan
@when("I run lifecycle coverage plan execute with plan ID")
@@ -121,8 +121,13 @@ def step_robot_alignment_service(context: Context) -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.ra_mock_service,
)
context.ra_executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
context.ra_action_patcher.start()
context.ra_plan_patcher.start()
context.ra_executor_patcher.start()
# Set up changeset store for tracking
context.ra_changeset_store = InMemoryChangeSetStore()
@@ -132,6 +137,7 @@ def step_robot_alignment_service(context: Context) -> None:
context._cleanup_handlers = []
context._cleanup_handlers.append(context.ra_action_patcher.stop)
context._cleanup_handlers.append(context.ra_plan_patcher.stop)
context._cleanup_handlers.append(context.ra_executor_patcher.stop)
# ---------------------------------------------------------------------------
@@ -146,6 +152,9 @@ def step_robot_alignment_action_create(context: Context, name: str) -> None:
context.ra_mock_service.create_action.return_value = action
context.ra_mock_service.get_action_by_name.return_value = action
context.ra_mock_service.use_action.return_value = _mock_plan()
context.ra_mock_service.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
context.ra_mock_service.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
@@ -6,6 +6,7 @@ conflicts with existing steps.
from __future__ import annotations
import contextlib
import json
from datetime import datetime
from pathlib import Path
@@ -442,10 +443,17 @@ def step_m1_plan_in_strategize(context: Context) -> None:
state=ProcessingState.COMPLETE,
)
context.mock_service.list_plans.return_value = [plan]
context.mock_service.get_plan.return_value = plan
context.mock_service.execute_plan.return_value = _make_m1_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.QUEUED,
)
# Patch the plan executor so the execute phase runs with a mock
context._m1_executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
context._m1_executor_patcher.start()
@when("I m1 smoke invoke plan execute")
@@ -453,6 +461,9 @@ def step_m1_plan_execute(context: Context) -> None:
"""Invoke plan execute."""
result = context.runner.invoke(plan_app, ["execute"])
context.last_result = result
# Clean up executor patcher if it was set
with contextlib.suppress(AttributeError):
context._m1_executor_patcher.stop()
@then("the m1 smoke plan execute should succeed")
@@ -134,6 +134,14 @@ def step_mocked_lifecycle(context: Any) -> None:
p.start()
context.r2_cleanups.append(p.stop)
# Patch the plan executor getter (needed by execute_plan command)
p2 = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
p2.start()
context.r2_cleanups.append(p2.stop)
# Replace the module-level console with a wider one for table tests
from rich.console import Console as RichConsole
@@ -190,6 +198,7 @@ def step_one_strategize_plan(context: Any) -> None:
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p]
context.r2_mock_svc.get_plan.return_value = p
context.r2_mock_svc.execute_plan.return_value = p
@@ -278,9 +278,18 @@ def step_service_has_strategize_plan(context) -> None:
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
)
context.mock_lifecycle_service.get_plan.return_value = plan
context.mock_lifecycle_service.execute_plan.return_value = plan
context._execute_plan_id = _ULIDS[0]
# Mock the plan executor so the execute phase runs with a mock
executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
executor_patcher.start()
context._cleanup_handlers.append(executor_patcher.stop)
@given("the service has a complete execute plan for apply")
def step_service_has_execute_plan(context) -> None:
@@ -411,6 +411,14 @@ def step_mock_lifecycle_for_execute(context: Context) -> None:
p = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p.start()
context._r2cov_cleanups.append(p.stop)
p_exec = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
p_exec.start()
context._r2cov_cleanups.append(p_exec.stop)
context.r2cov_service = mock_service
@@ -232,13 +232,35 @@ def step_plan_execute_without_plan_id(context, count: int) -> None:
]
context.lifecycle_service.list_plans.return_value = plans
if count == 1:
context.lifecycle_service.get_plan.return_value = plans[0]
context.lifecycle_service.execute_plan.return_value = plans[0]
context.execute_plans = plans
# Mock the plan executor for successful execute paths
mock_executor = MagicMock()
executor_patcher = patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=mock_executor,
)
executor_patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(executor_patcher.stop)
context.result = context.runner.invoke(plan_app, ["execute"])
@when('I run plan execute for plan id "{plan_id}" causing "{error_type}"')
def step_plan_execute_error(context, plan_id: str, error_type: str) -> None:
# Provide a real plan so phase checks pass before the error is raised
error_plan = _make_plan(
plan_id=plan_id,
name="local/error-plan",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.COMPLETE,
)
context.lifecycle_service.get_plan.return_value = error_plan
if error_type == "invalid transition":
context.lifecycle_service.execute_plan.side_effect = (
InvalidPhaseTransitionError(
@@ -450,10 +450,19 @@ def step_invoke_execute_plan_with_id(context):
mock_service = MagicMock()
mock_plan = _make_mock_lifecycle_plan(phase="execute", state="queued")
mock_service.execute_plan.return_value = mock_plan
mock_service.get_plan.return_value = mock_plan
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
mock_executor = MagicMock()
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=mock_executor,
),
):
result = runner.invoke(plan_app, ["execute", "01JAAAAAAAAAAAAAAAAAAAAAAA"])
context.result = result
@@ -466,14 +475,23 @@ def step_invoke_execute_plan_auto_select(context):
mock_service = MagicMock()
plan = _make_mock_lifecycle_plan(phase="strategize", state="complete")
mock_service.list_plans.return_value = [plan]
mock_service.get_plan.return_value = plan
mock_service.execute_plan.return_value = _make_mock_lifecycle_plan(
phase="execute",
state="queued",
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
mock_executor = MagicMock()
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=mock_executor,
),
):
result = runner.invoke(plan_app, ["execute"])
context.result = result
@@ -483,8 +501,8 @@ def step_invoke_execute_plan_auto_select(context):
def step_invoke_execute_plan_no_ready(context):
runner = CliRunner()
mock_service = MagicMock()
# Return a plan that is in strategize but still queued (not complete)
plan = _make_mock_lifecycle_plan(phase="strategize", state="queued")
# Return a plan that is NOT in an executable state (e.g. already applied)
plan = _make_mock_lifecycle_plan(phase="apply", state="applied")
mock_service.list_plans.return_value = [plan]
with patch(
@@ -529,6 +547,9 @@ def step_invoke_execute_plan_invalid_transition(context):
runner = CliRunner()
mock_service = MagicMock()
# Provide a real plan so the phase check passes before the error is hit
mock_plan = _make_mock_lifecycle_plan(phase="strategize", state="complete")
mock_service.get_plan.return_value = mock_plan
mock_service.execute_plan.side_effect = InvalidPhaseTransitionError(
PlanPhase.APPLY,
PlanPhase.EXECUTE,
@@ -551,6 +572,9 @@ def step_invoke_execute_plan_not_ready(context):
runner = CliRunner()
mock_service = MagicMock()
# Provide a real plan so the phase check passes before the error is hit
mock_plan = _make_mock_lifecycle_plan(phase="strategize", state="complete")
mock_service.get_plan.return_value = mock_plan
mock_service.execute_plan.side_effect = PlanNotReadyError(
"01JAAAAAAAAAAAAAAAAAAAAAAA",
PlanPhase.STRATEGIZE,
+1 -1
View File
@@ -759,7 +759,7 @@ Run Python Script
# Create File writes to it (avoids leaking an open descriptor).
${temp_file}= Evaluate (lambda t: (__import__('os').close(t[0]), t[1])[-1])(__import__('tempfile').mkstemp(suffix='.py', dir='/tmp'))
Create File ${temp_file} ${full_code}
${result}= Run Process ${PYTHON} ${temp_file} timeout=60s stderr=STDOUT env:PYTHONWARNINGS=ignore env:PYTHONDONTWRITEBYTECODE=1
${result}= Run Process ${PYTHON} ${temp_file} timeout=120s stderr=STDOUT env:PYTHONWARNINGS=ignore env:PYTHONDONTWRITEBYTECODE=1
Remove File ${temp_file}
# Check if process failed and log stderr if present
IF ${result.rc} != 0
+11 -10
View File
@@ -26,15 +26,16 @@ M1 Full Plan Lifecycle
# ── 2. Write action YAML config ──────────────────────────────
${action_yaml}= Set Variable ${SUITE_HOME}${/}m1_test_action.yaml
Create File ${action_yaml}
... name: local/test-action\n
... description: "M1 E2E acceptance test action"\n
... strategy_actor: openai/gpt-4o-mini\n
... execution_actor: openai/gpt-4o-mini\n
... definition_of_done: |\n
... ${SPACE}${SPACE}Create a file called HELLO.md with a short greeting.\n
... reusable: true\n
... read_only: false\n
${yaml_content}= Catenate SEPARATOR=\n
... name: local/test-action
... description: "M1 E2E acceptance test action"
... strategy_actor: openai/gpt-4o-mini
... execution_actor: openai/gpt-4o-mini
... definition_of_done: |
... ${SPACE}${SPACE}Create a file called HELLO.md with a short greeting.
... reusable: true
... read_only: false
Create File ${action_yaml} ${yaml_content}
# ── 3. Create the action ─────────────────────────────────────
${action_result}= Run CleverAgents Command
@@ -44,7 +45,7 @@ M1 Full Plan Lifecycle
# ── 4. Register git-checkout resource ────────────────────────
${resource_result}= Run CleverAgents Command
... resource add git-checkout local/test-repo
... --path ${repo_path} --branch main
... --path ${repo_path} --branch master
Should Be Equal As Integers ${resource_result.rc} 0
... Resource add failed: ${resource_result.stderr}
+18 -2
View File
@@ -179,6 +179,9 @@ def plan_execute_changeset() -> None:
"""Verify plan execute and ChangeSet capture records entries."""
svc = MagicMock()
svc.execute_plan.return_value = _mock_plan(phase=PlanPhase.EXECUTE)
svc.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
store, cid = _make_store()
store.record(
@@ -192,8 +195,14 @@ def plan_execute_changeset() -> None:
),
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service", return_value=svc
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service", return_value=svc
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = runner.invoke(plan_app, ["execute", _PLAN_ULID])
if result.exit_code != 0:
@@ -260,6 +269,9 @@ def full_lifecycle() -> None:
svc.get_action_by_name.return_value = _mock_action()
svc.use_action.return_value = _mock_plan()
svc.execute_plan.return_value = _mock_plan(phase=PlanPhase.EXECUTE)
svc.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
svc.apply_plan.return_value = _mock_plan(phase=PlanPhase.APPLY)
sandbox_dir = tempfile.mkdtemp(prefix="robot_full_sandbox_")
@@ -286,6 +298,10 @@ def full_lifecycle() -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=svc,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
r1 = runner.invoke(action_app, ["create", "--config", yaml_path])
assert r1.exit_code == 0, f"action create: {r1.output}"
+19 -3
View File
@@ -154,13 +154,22 @@ def plan_use() -> None:
def plan_execute() -> None:
"""Verify plan execute transitions to Execute phase."""
mock_service = MagicMock()
mock_service.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
mock_service.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = runner.invoke(plan_app, ["execute", _PLAN_ULID])
if result.exit_code == 0:
@@ -267,6 +276,9 @@ def full_lifecycle() -> None:
mock_service.use_action.return_value = plan_strat
mock_service.execute_plan.return_value = plan_exec
mock_service.get_plan.return_value = _mock_plan(
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
mock_service.apply_plan.return_value = plan_apply
yaml_path = _write_yaml(_VALID_YAML)
@@ -280,6 +292,10 @@ def full_lifecycle() -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
# Step 1: Create action
r1 = runner.invoke(action_app, ["create", "--config", yaml_path])
+15 -3
View File
@@ -152,12 +152,19 @@ def plan_execute() -> None:
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
mock_svc.list_plans.return_value = [strategize_plan]
mock_svc.get_plan.return_value = strategize_plan
mock_svc.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = runner.invoke(plan_app, ["execute"])
if result.exit_code == 0 and "execute" in result.output.lower():
@@ -212,6 +219,7 @@ def full_lifecycle() -> None:
phase=PlanPhase.STRATEGIZE, state=ProcessingState.COMPLETE
)
mock_svc.list_plans.return_value = [strategize_plan]
mock_svc.get_plan.return_value = strategize_plan
mock_svc.execute_plan.return_value = _mock_plan(
phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED
)
@@ -228,6 +236,10 @@ def full_lifecycle() -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
# Step 1: action create
r1 = runner.invoke(action_app, ["create", "--config", str(config_path)])
+9 -6
View File
@@ -239,16 +239,19 @@ def cli_plan_execute() -> None:
)
mock_service.execute_plan.return_value = post_plan
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = _runner.invoke(plan_app, ["execute", _ROOT_ULID])
_assert_exit_code(result, "plan execute")
# Verify get_plan was called for the read-only guard.
_assert_mock_called_once_with(mock_service.get_plan, "get_plan", _ROOT_ULID)
# Verify execute_plan was called with the plan ID
_assert_mock_called_once_with(
mock_service.execute_plan, "execute_plan", _ROOT_ULID
+9 -3
View File
@@ -64,9 +64,15 @@ def cli_plan_execute_readonly() -> None:
)
mock_service.get_plan.return_value = plan
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_service,
),
patch(
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
),
):
result = _runner.invoke(plan_app, ["execute", _ROOT_ULID])
if result.exit_code == 0:
+1 -1
View File
@@ -94,7 +94,7 @@ Correction Live Revert Executes And Re-Creates Decisions
... Validates: plan correct with --mode revert executes
... live correction.
[Tags] success_criteria correction_live_revert
${result}= Run Process ${PYTHON} ${HELPER} correction-live-revert cwd=${WORKSPACE} timeout=60s
${result}= Run Process ${PYTHON} ${HELPER} correction-live-revert cwd=${WORKSPACE} timeout=120s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
+3 -3
View File
@@ -13,7 +13,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_create_di.py
TDD Session Create DI Error Via CLI
[Documentation] Verify that ``session create`` triggers the DI db error
[Tags] tdd_bug tdd_bug_570
${result}= Run Process ${PYTHON} ${HELPER} create-di-error cwd=${WORKSPACE} timeout=30s
${result}= Run Process ${PYTHON} ${HELPER} create-di-error cwd=${WORKSPACE} timeout=90s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
@@ -22,7 +22,7 @@ TDD Session Create DI Error Via CLI
TDD Session Create With Actor DI Error
[Documentation] Verify that ``session create --actor`` triggers the DI db error
[Tags] tdd_bug tdd_bug_570
${result}= Run Process ${PYTHON} ${HELPER} create-actor cwd=${WORKSPACE} timeout=30s
${result}= Run Process ${PYTHON} ${HELPER} create-actor cwd=${WORKSPACE} timeout=90s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
@@ -31,7 +31,7 @@ TDD Session Create With Actor DI Error
TDD Session Create DI JSON Output
[Documentation] Verify that ``session create --format json`` fails due to DI db error
[Tags] tdd_bug tdd_bug_570
${result}= Run Process ${PYTHON} ${HELPER} create-json cwd=${WORKSPACE} timeout=30s
${result}= Run Process ${PYTHON} ${HELPER} create-json cwd=${WORKSPACE} timeout=90s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
@@ -51,6 +51,7 @@ Based on ``docs/specification.md`` and implementation plan Stage A3.
from __future__ import annotations
from contextlib import suppress
from datetime import datetime
from typing import TYPE_CHECKING, Any
@@ -848,6 +849,11 @@ class PlanLifecycleService:
)
# -- Pre-flight guardrail checks ----------------------------------
# Ensure the plan's action is loaded from the persistence layer
# into the in-memory cache so the guardrail can find it.
if plan.action_name:
with suppress(Exception):
self.get_action(plan.action_name)
action_registry: dict[str, object] = {
name: act for name, act in self._actions.items()
}
+5 -1
View File
@@ -82,7 +82,11 @@ _FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)
def _get_lifecycle_service() -> PlanLifecycleService:
"""Get the PlanLifecycleService from the container."""
"""Get the PlanLifecycleService from the container.
Uses the container's registered provider which includes UnitOfWork
for database persistence across subprocess invocations.
"""
container = get_container()
return container.plan_lifecycle_service()
+203 -31
View File
@@ -742,23 +742,42 @@ def build(
@app.command()
def apply(
plan_id: Annotated[
str | None,
typer.Argument(help="Plan ID to apply (v3 lifecycle). Omit for legacy mode."),
] = None,
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
fmt: Annotated[
str,
typer.Option(
"--format",
"-f",
help=_FORMAT_HELP,
),
] = "rich",
) -> None:
"""Apply the built plan changes to the filesystem.
"""Apply plan changes.
This command writes the AI-generated changes to your actual files.
When a *plan_id* is given, uses the v3 lifecycle: transitions the
plan through Apply/queued -> Apply/processing -> Apply/applied.
.. deprecated::
Use ``agents plan lifecycle-apply`` for the v3 lifecycle.
When no *plan_id* is given, falls back to the legacy workflow that
writes AI-generated file changes for the current project.
"""
if plan_id is not None:
# ── v3 lifecycle apply ──────────────────────────────────
_lifecycle_apply_with_id(plan_id, fmt)
return
# ── Legacy apply path (no plan_id) ──────────────────────────
import os
from cleveragents.application.container import get_container
from cleveragents.application.services.plan_service import PlanService
console.print(
"[yellow]Warning:[/yellow] 'apply' is a legacy command. "
"Use 'agents plan lifecycle-apply <plan_id>' for the v3 lifecycle."
"[yellow]Warning:[/yellow] 'apply' without a plan ID is a legacy command. "
"Use 'agents plan apply <plan_id>' for the v3 lifecycle."
)
try:
@@ -832,6 +851,74 @@ def apply(
raise typer.Abort() from e
def _lifecycle_apply_with_id(plan_id: str, fmt: str = "rich") -> None:
"""Run the v3 lifecycle apply for a specific plan.
Transitions the plan through:
Execute/complete -> Apply/queued -> Apply/processing -> Apply/applied.
"""
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
PlanNotReadyError,
)
try:
service = _get_lifecycle_service()
# Fail-fast: read-only plans must not enter Apply phase
pre_plan = service.get_plan(plan_id)
if pre_plan is None:
console.print(f"[red]Plan '{plan_id}' not found.[/red]")
raise typer.Abort()
if pre_plan.read_only is True:
console.print(
f"[red]Cannot apply plan '{plan_id}': plan is read-only.[/red]"
)
raise typer.Abort()
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProcessingState,
)
# Determine current phase and drive through apply
if (
pre_plan.phase == PlanPhase.EXECUTE
and pre_plan.state == ProcessingState.COMPLETE
):
# Transition Execute/complete -> Apply/queued
service.apply_plan(plan_id)
current = service.get_plan(plan_id)
if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED:
service.start_apply(plan_id)
current = service.get_plan(plan_id)
if (
current.phase == PlanPhase.APPLY
and current.state == ProcessingState.PROCESSING
):
service.complete_apply(plan_id)
plan = service.get_plan(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
else:
_print_lifecycle_plan(plan, title="Plan Applied")
console.print("\n[dim]Plan apply completed successfully.[/dim]")
except InvalidPhaseTransitionError as e:
console.print(f"[red]Invalid transition:[/red] {e}")
raise typer.Abort() from e
except PlanNotReadyError as e:
console.print(f"[red]Plan not ready:[/red] {e}")
raise typer.Abort() from e
except CleverAgentsError as e:
console.print(f"[red]Error:[/red] {e.message}")
raise typer.Abort() from e
@app.command()
def new(
name: Annotated[
@@ -1103,13 +1190,28 @@ def continue_plan(
def _get_lifecycle_service():
"""Get the PlanLifecycleService from the container."""
"""Get the PlanLifecycleService from the container.
Uses the container's registered provider which includes UnitOfWork
for database persistence across subprocess invocations.
"""
from cleveragents.application.container import get_container
container = get_container()
return container.plan_lifecycle_service()
def _get_plan_executor():
"""Get a PlanExecutor wired with the container's lifecycle service.
Returns a PlanExecutor that uses stub actors for M1 phase processing.
"""
from cleveragents.application.services.plan_executor import PlanExecutor
lifecycle = _get_lifecycle_service()
return PlanExecutor(lifecycle_service=lifecycle)
def _print_lifecycle_plan(plan: Any, title: str = "Plan") -> None:
"""Print v3 lifecycle plan details in a nice panel.
@@ -1518,9 +1620,15 @@ def execute_plan(
),
] = "rich",
) -> None:
"""Execute a plan, transitioning from Strategize to Execute phase.
"""Execute the current plan phase synchronously.
The plan must be in Strategize phase with 'complete' state.
Detects the plan's current phase and processes it:
- **Strategize/queued**: runs the strategize phase to completion.
- **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.
"""
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
@@ -1528,32 +1636,42 @@ def execute_plan(
)
try:
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProcessingState,
)
service = _get_lifecycle_service()
executor = _get_plan_executor()
if not plan_id:
# Try to find the only plan in strategize phase
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProcessingState,
)
plans = service.list_plans(phase=PlanPhase.STRATEGIZE)
complete_plans = [p for p in plans if p.state == ProcessingState.COMPLETE]
if len(complete_plans) == 0:
# Auto-discover: look for plans in strategize or execute
plans_strat = service.list_plans(phase=PlanPhase.STRATEGIZE)
plans_exec = service.list_plans(phase=PlanPhase.EXECUTE)
eligible = [
p
for p in plans_strat
if p.state in (ProcessingState.QUEUED, ProcessingState.COMPLETE)
] + [p for p in plans_exec if p.state == ProcessingState.QUEUED]
if len(eligible) == 0:
console.print(
"[yellow]No plans ready for execution.[/yellow]\n"
"Plans must be in Strategize phase with 'complete' state."
"Plans must be in Strategize or Execute phase."
)
raise typer.Abort()
if len(complete_plans) > 1:
if len(eligible) > 1:
console.print(
"[yellow]Multiple plans ready for execution. "
"Please specify a plan ID.[/yellow]"
)
for p in complete_plans:
console.print(f"{p.identity.plan_id}: {p.namespaced_name}")
for p in eligible:
console.print(
f" * {p.identity.plan_id}: "
f"{p.namespaced_name} "
f"({p.phase.value}/{p.state.value})"
)
raise typer.Abort()
plan_id = complete_plans[0].identity.plan_id
plan_id = eligible[0].identity.plan_id
# Apply execution environment override if provided
if execution_environment:
@@ -1580,18 +1698,72 @@ def execute_plan(
)
raise typer.Abort()
# Execute the plan
plan = service.execute_plan(plan_id)
# Determine current phase and run the appropriate processing
current_plan = service.get_plan(plan_id)
if current_plan is None:
console.print(f"[red]Plan '{plan_id}' not found.[/red]")
raise typer.Abort()
if current_plan.phase == PlanPhase.STRATEGIZE and current_plan.state in (
ProcessingState.QUEUED,
ProcessingState.PROCESSING,
):
# Run strategize phase synchronously
executor.run_strategize(plan_id)
plan = service.get_plan(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
else:
_print_lifecycle_plan(plan, title="Strategize Complete")
console.print(
"\n[dim]Strategize phase completed. "
"Run 'agents plan execute <id>' again to start "
"the Execute phase.[/dim]"
)
elif (
current_plan.phase == PlanPhase.STRATEGIZE
and current_plan.state == ProcessingState.COMPLETE
):
# Transition to Execute and run it synchronously
service.execute_plan(plan_id)
executor.run_execute(plan_id)
plan = service.get_plan(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
else:
_print_lifecycle_plan(plan, title="Execute Complete")
console.print(
"\n[dim]Execute phase completed. "
"Run 'agents plan apply <id>' to apply changes.[/dim]"
)
elif (
current_plan.phase == PlanPhase.EXECUTE
and current_plan.state == ProcessingState.QUEUED
):
# Run execute phase synchronously
executor.run_execute(plan_id)
plan = service.get_plan(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
else:
_print_lifecycle_plan(plan, title="Execute Complete")
console.print(
"\n[dim]Execute phase completed. "
"Run 'agents plan apply <id>' to apply changes.[/dim]"
)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
else:
_print_lifecycle_plan(plan, title="Plan Executing")
console.print(
"\n[dim]Plan is now in Execute phase (queued). "
"Run 'agents plan apply <id>' when execution is complete.[/dim]"
f"[red]Plan '{plan_id}' is not in an executable state "
f"(current: {current_plan.phase.value}/"
f"{current_plan.state.value}).[/red]"
)
raise typer.Abort()
except InvalidPhaseTransitionError as e:
console.print(f"[red]Invalid transition:[/red] {e}")