Files
temp/features/steps/cli_lifecycle_robot_alignment_steps.py
T
freemo 5f07316641 fix: wire DI persistence and plan execute/apply for M1 lifecycle
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
2026-03-15 20:50:02 +00:00

327 lines
12 KiB
Python

"""Step definitions for CLI lifecycle Robot alignment feature.
Mirrors the Robot E2E lifecycle flow (action -> plan -> execute -> apply)
in Behave to keep unit and integration test expectations aligned.
All step names are prefixed with ``robot alignment`` to avoid
``AmbiguousStep`` conflicts with existing steps.
"""
from __future__ import annotations
import os
import tempfile
from datetime import datetime
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.action import app as action_app
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
InMemoryChangeSetStore,
)
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_PLAN_ULID = "01KHDE6WWS2171PWW3GJEBXZ8T"
_VALID_YAML = """\
name: local/lifecycle-action
description: Lifecycle test action
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: All lifecycle tests pass
"""
def _mock_action(name: str = "local/lifecycle-action") -> Action:
"""Create a minimal valid Action for alignment tests."""
return Action(
namespaced_name=NamespacedName.parse(name),
description="Lifecycle test action",
long_description=None,
definition_of_done="All lifecycle tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
state=ActionState.AVAILABLE,
reusable=True,
read_only=False,
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=None,
)
def _mock_plan(
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
) -> Plan:
"""Create a minimal valid Plan for alignment tests."""
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_PLAN_ULID),
namespaced_name=NamespacedName.parse("local/lifecycle-plan"),
description="Lifecycle test plan",
definition_of_done="Tests pass",
action_name="local/lifecycle-action",
phase=phase,
processing_state=state,
project_links=[ProjectLink(project_name="proj-a")],
arguments={"target_coverage": 80},
arguments_order=["target_coverage"],
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.PLAN,
),
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
reusable=True,
read_only=False,
created_by=None,
timestamps=PlanTimestamps(created_at=now, updated_at=now),
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a robot alignment CLI runner")
def step_robot_alignment_runner(context: Context) -> None:
"""Set up the CLI runner."""
context.ra_runner = CliRunner()
@given("a robot alignment mocked lifecycle service")
def step_robot_alignment_service(context: Context) -> None:
"""Set up a mock PlanLifecycleService for the plan CLI."""
context.ra_mock_service = MagicMock()
context.ra_action_patcher = patch(
"cleveragents.cli.commands.action._get_lifecycle_service",
return_value=context.ra_mock_service,
)
context.ra_plan_patcher = patch(
"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()
context.ra_changeset_id = context.ra_changeset_store.start(_PLAN_ULID)
if not hasattr(context, "_cleanup_handlers"):
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)
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given('a robot alignment action "{name}" is created via config')
def step_robot_alignment_action_create(context: Context, name: str) -> None:
"""Create action via CLI config and configure mocks."""
action = _mock_action(name)
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
)
context.ra_mock_service.apply_plan.return_value = _mock_plan(
phase=PlanPhase.APPLY, state=ProcessingState.QUEUED
)
fd, yaml_path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(_VALID_YAML)
context.ra_yaml_path = yaml_path
result = context.ra_runner.invoke(action_app, ["create", "--config", yaml_path])
context.ra_action_result = result
os.unlink(yaml_path)
assert result.exit_code == 0, (
f"action create failed ({result.exit_code}): {result.output}"
)
@given('a robot alignment action "{name}" exists')
def step_robot_alignment_action_exists(context: Context, name: str) -> None:
"""Set up an existing action mock."""
context.ra_mock_service.get_action_by_name.return_value = _mock_action(name)
context.ra_mock_service.use_action.return_value = _mock_plan()
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I run robot alignment plan use "{action}" on project "{project}"')
def step_robot_alignment_plan_use(context: Context, action: str, project: str) -> None:
"""Run plan use."""
context.ra_use_result = context.ra_runner.invoke(plan_app, ["use", action, project])
@when("I run robot alignment plan execute for the plan")
def step_robot_alignment_plan_execute(context: Context) -> None:
"""Run plan execute and record a changeset entry."""
# Record a change entry to mirror the Robot test
context.ra_changeset_store.record(
context.ra_changeset_id,
ChangeEntry(
plan_id=_PLAN_ULID,
resource_id="res-a",
tool_name="builtin/file-write",
operation=ChangeOperation.CREATE,
path="src/new_module.py",
),
)
context.ra_execute_result = context.ra_runner.invoke(
plan_app, ["execute", _PLAN_ULID]
)
@when("I run robot alignment plan apply for the plan")
def step_robot_alignment_plan_apply(context: Context) -> None:
"""Run plan apply and record an additional changeset entry."""
# Record a modify entry to bring total to 2
context.ra_changeset_store.record(
context.ra_changeset_id,
ChangeEntry(
plan_id=_PLAN_ULID,
resource_id="res-a",
tool_name="builtin/file-edit",
operation=ChangeOperation.MODIFY,
path="src/existing.py",
),
)
context.ra_apply_result = context.ra_runner.invoke(
plan_app, ["lifecycle-apply", _PLAN_ULID]
)
@when('I run robot alignment plan use with invalid arg "{arg_str}"')
def step_robot_alignment_invalid_arg(context: Context, arg_str: str) -> None:
"""Run plan use with an invalid --arg format."""
context.ra_invalid_arg_result = context.ra_runner.invoke(
plan_app,
["use", "local/lifecycle-action", "proj-a", "--arg", arg_str],
)
@when('I run robot alignment plan use for nonexistent action "{action}"')
def step_robot_alignment_missing_action(context: Context, action: str) -> None:
"""Run plan use for an action that doesn't exist."""
context.ra_mock_service.get_action_by_name.side_effect = NotFoundError(
f"Action '{action}' not found"
)
context.ra_missing_result = context.ra_runner.invoke(
plan_app, ["use", action, "proj-a"]
)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("the robot alignment plan use should succeed")
def step_robot_alignment_plan_use_ok(context: Context) -> None:
"""Verify plan use succeeded."""
assert context.ra_use_result.exit_code == 0, (
f"plan use failed ({context.ra_use_result.exit_code}): "
f"{context.ra_use_result.output}"
)
@then("the robot alignment plan execute should succeed")
def step_robot_alignment_plan_execute_ok(context: Context) -> None:
"""Verify plan execute succeeded."""
assert context.ra_execute_result.exit_code == 0, (
f"plan execute failed ({context.ra_execute_result.exit_code}): "
f"{context.ra_execute_result.output}"
)
@then('the robot alignment changeset should have {count:d} entry with operation "{op}"')
def step_robot_alignment_changeset_entry(context: Context, count: int, op: str) -> None:
"""Verify changeset entries."""
cs = context.ra_changeset_store.get(context.ra_changeset_id)
assert cs is not None, "changeset not found"
assert len(cs.entries) == count, f"expected {count} entries, got {len(cs.entries)}"
assert cs.entries[0].operation == op, (
f"expected operation={op}, got {cs.entries[0].operation}"
)
@then("the robot alignment plan apply should succeed")
def step_robot_alignment_plan_apply_ok(context: Context) -> None:
"""Verify plan apply succeeded."""
assert context.ra_apply_result.exit_code == 0, (
f"plan apply failed ({context.ra_apply_result.exit_code}): "
f"{context.ra_apply_result.output}"
)
@then("the robot alignment changeset summary should have total {total:d}")
def step_robot_alignment_changeset_total(context: Context, total: int) -> None:
"""Verify changeset summary total."""
summary = context.ra_changeset_store.summarize(context.ra_changeset_id)
assert summary.get("total") == total, (
f"expected total={total}, got {summary.get('total')}"
)
@then("the robot alignment CLI should reject the invalid arg format")
def step_robot_alignment_invalid_arg_rejected(context: Context) -> None:
"""Verify the CLI rejected the invalid arg format."""
result = context.ra_invalid_arg_result
output_lower = result.output.lower()
assert (
result.exit_code != 0
or "invalid argument format" in output_lower
or "aborted" in output_lower
), f"expected rejection, got exit_code={result.exit_code}: {result.output}"
@then("the robot alignment CLI should report action not found")
def step_robot_alignment_action_not_found(context: Context) -> None:
"""Verify the CLI reported action not found."""
result = context.ra_missing_result
output_lower = result.output.lower()
assert (
result.exit_code != 0 or "not found" in output_lower or "error" in output_lower
), f"expected not-found error, got exit_code={result.exit_code}: {result.output}"