Files
temp/features/steps/plan_cli_coverage_r2_steps.py
T
freemo 38e05ac45a feat(correction): wire checkpoint rollback into correction service revert flow
Implement the full correction-checkpoint rollback pipeline:

- Workspace snapshots: CheckpointService.create_workspace_snapshot()
  creates diff-based checkpoints before decision execution, storing
  only changed file paths in metadata.extra["diff_paths"]

- CorrectionService.revert_decisions(): new high-level entry point
  that creates a correction, computes impact, invokes checkpoint
  rollback, and archives artifacts in a single call

- Physical artifact archival: CheckpointService.archive_artifacts()
  moves files to .cleveragents/archived_artifacts/ instead of just
  flagging metadata. CorrectionService._archive_decision_artifacts()
  delegates to this during revert execution

- Selective rollback: CheckpointService.selective_rollback() wraps
  rollback_to_checkpoint with atomic semantics — captures HEAD before
  rollback and recovers on failure

- Diff-based storage: _compute_diff_snapshot() computes changed paths
  between checkpoints via git diff; snapshots store diff manifest and
  SHA-256 hash in metadata

- CLI: plan rollback now accepts --to-checkpoint <id> in addition to
  the positional checkpoint_id argument; uses selective_rollback for
  atomic execution

- DI wiring: Container now injects checkpoint_service into
  CorrectionService; CLI correct command uses container-provided
  service instead of ad-hoc instance (fixes bug #986)

- Checkpoint model: pre_decision added to allowed checkpoint_type
  values

- TDD: Removed @tdd_expected_fail from wiring test feature since the
  DI bug is now fixed

ISSUES CLOSED: #943
2026-03-30 17:06:49 -04:00

740 lines
25 KiB
Python

"""Step definitions for plan_cli_coverage_r2.feature.
Targets remaining uncovered lines in cleveragents/cli/commands/plan.py:
- Line 724: build >5 changes truncation ("... and N more")
- Lines 737-738: build PlanError handler
- Lines 740-741: build CleverAgentsError handler
- Lines 832-833: apply CleverAgentsError handler
- Lines 879-880: new ValidationError handler
- Lines 882-883: new CleverAgentsError handler
- Lines 930-931: current CleverAgentsError handler
- Lines 1007-1008: list CleverAgentsError handler
- Lines 1094-1095: continue CleverAgentsError handler
- Lines 1108-1115: _get_lifecycle_service body
- Lines 1223-1245: Multi-project scopes rendering in _print_lifecycle_plan
- Lines 1469-1481: use_action execution_environment validation
- Lines 1565-1578: execute_plan execution_environment validation
- Lines 2588, 2593, 2598: resume_plan rich output optional branches
- Lines 2616-2618: resume CleverAgentsError handler
- Lines 2691-2762: rollback_plan full command body
"""
from __future__ import annotations
import io
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from rich.console import Console
from typer.testing import CliRunner
from cleveragents.cli.commands import plan as plan_module
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import (
BusinessRuleViolation,
CleverAgentsError,
PlanError,
ResourceNotFoundError,
ValidationError,
)
from cleveragents.domain.models.core.checkpoint import RollbackResult
from cleveragents.domain.models.core.multi_project import (
ChangeSetSummary,
MultiProjectMetadata,
ProjectScope,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
from cleveragents.domain.models.core.resume import ResumeSummary
_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FBV"
_ULID_CP = "01ARZ3NDEKTSV4RRFFQ69G5FCV"
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
_PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
_PATCH_GET_PROJECT = "cleveragents.cli.commands.plan._get_current_project"
_PATCH_GET_APPLY = "cleveragents.cli.commands.plan._get_apply_service"
def _make_legacy_project() -> SimpleNamespace:
return SimpleNamespace(name="test-project", path="/tmp/test")
def _make_legacy_plan(
*,
name: str = "my-plan",
status: str = "active",
current: bool = True,
) -> SimpleNamespace:
return SimpleNamespace(
name=name,
status=status,
current=current,
created_at=datetime(2025, 1, 1),
prompt="do stuff",
)
def _make_legacy_change(file_path: str, operation: str = "modify") -> SimpleNamespace:
return SimpleNamespace(file_path=file_path, operation=operation)
def _make_lifecycle_plan(
*,
plan_id: str = _ULID_A,
name: str = "local/test-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
error_message: str | None = None,
error_details: dict[str, str] | None = None,
read_only: bool = False,
multi_project_metadata: MultiProjectMetadata | None = None,
execution_environment: str | None = None,
) -> Plan:
plan = Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name="local/test-action",
description="Coverage test plan",
definition_of_done=None,
strategy_actor=None,
execution_actor=None,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 15, 10, 0, 0),
updated_at=datetime(2025, 6, 15, 11, 0, 0),
),
error_message=error_message,
error_details=error_details,
reusable=True,
read_only=read_only,
created_by=None,
multi_project_metadata=multi_project_metadata,
)
if execution_environment:
plan.execution_environment = execution_environment
return plan
def _setup_legacy_mocks(
context: Context,
*,
plan_service_attrs: dict[str, Any] | None = None,
project_service_attrs: dict[str, Any] | None = None,
) -> list:
"""Set up standard legacy container mocks and return patches for cleanup."""
mock_container = MagicMock()
mock_plan_svc = MagicMock()
mock_project_svc = MagicMock()
mock_project_svc.get_current_project.return_value = _make_legacy_project()
if plan_service_attrs:
for key, val in plan_service_attrs.items():
setattr(mock_plan_svc, key, val)
mock_container.plan_service.return_value = mock_plan_svc
mock_container.project_service.return_value = mock_project_svc
# actor_registry for build/tell
mock_container.actor_registry.return_value = MagicMock()
mock_container.actor_service.return_value = MagicMock()
patches = []
p_container = patch(_PATCH_CONTAINER, return_value=mock_container)
p_project = patch(_PATCH_GET_PROJECT, return_value=_make_legacy_project())
p_container.start()
p_project.start()
patches.extend([p_container.stop, p_project.stop])
context.r2cov_plan_svc = mock_plan_svc
context.r2cov_container = mock_container
return patches
# ======================================================================
# Given — CLI runner
# ======================================================================
@given("a r2cov CLI runner")
def step_r2cov_cli_runner(context: Context) -> None:
context.r2cov_runner = CliRunner()
context.r2cov_result = None
if not hasattr(context, "_r2cov_cleanups"):
context._r2cov_cleanups = []
def _cleanup(ctx: Context) -> None:
for fn in getattr(ctx, "_r2cov_cleanups", []):
fn()
ctx._r2cov_cleanups = []
context.add_cleanup(_cleanup, context)
# ======================================================================
# Given — Legacy build mocks
# ======================================================================
@given("a mocked legacy container for r2cov build returning 8 changes")
def step_mock_build_8_changes(context: Context) -> None:
changes = [_make_legacy_change(f"src/file{i}.py") for i in range(8)]
build_plan = MagicMock(return_value=changes)
patches = _setup_legacy_mocks(
context, plan_service_attrs={"build_plan": build_plan}
)
context._r2cov_cleanups.extend(patches)
@given("a mocked legacy container for r2cov build that raises PlanError")
def step_mock_build_plan_error(context: Context) -> None:
build_plan = MagicMock(side_effect=PlanError("Build failed unexpectedly"))
patches = _setup_legacy_mocks(
context, plan_service_attrs={"build_plan": build_plan}
)
context._r2cov_cleanups.extend(patches)
@given("a mocked legacy container for r2cov build that raises CleverAgentsError")
def step_mock_build_clever_error(context: Context) -> None:
build_plan = MagicMock(side_effect=CleverAgentsError("service unavailable"))
patches = _setup_legacy_mocks(
context, plan_service_attrs={"build_plan": build_plan}
)
context._r2cov_cleanups.extend(patches)
# ======================================================================
# Given — Legacy apply mocks
# ======================================================================
@given("a mocked legacy container for r2cov apply that raises CleverAgentsError")
def step_mock_apply_clever_error(context: Context) -> None:
get_pending = MagicMock(return_value=[_make_legacy_change("f.py")])
apply_changes = MagicMock(side_effect=CleverAgentsError("apply failed"))
patches = _setup_legacy_mocks(
context,
plan_service_attrs={
"get_pending_changes": get_pending,
"apply_changes": apply_changes,
},
)
context._r2cov_cleanups.extend(patches)
# ======================================================================
# Given — Legacy new mocks
# ======================================================================
@given("a mocked legacy container for r2cov new that raises ValidationError")
def step_mock_new_validation_error(context: Context) -> None:
new_plan = MagicMock(side_effect=ValidationError("Invalid plan name"))
patches = _setup_legacy_mocks(context, plan_service_attrs={"new_plan": new_plan})
context._r2cov_cleanups.extend(patches)
@given("a mocked legacy container for r2cov new that raises CleverAgentsError")
def step_mock_new_clever_error(context: Context) -> None:
new_plan = MagicMock(side_effect=CleverAgentsError("new plan failed"))
patches = _setup_legacy_mocks(context, plan_service_attrs={"new_plan": new_plan})
context._r2cov_cleanups.extend(patches)
# ======================================================================
# Given — Legacy current mocks
# ======================================================================
@given("a mocked legacy container for r2cov current that raises CleverAgentsError")
def step_mock_current_clever_error(context: Context) -> None:
get_current = MagicMock(side_effect=CleverAgentsError("DB connection lost"))
patches = _setup_legacy_mocks(
context, plan_service_attrs={"get_current_plan": get_current}
)
context._r2cov_cleanups.extend(patches)
# ======================================================================
# Given — Legacy list mocks
# ======================================================================
@given("a mocked legacy container for r2cov list that raises CleverAgentsError")
def step_mock_list_clever_error(context: Context) -> None:
list_plans = MagicMock(side_effect=CleverAgentsError("list failed"))
patches = _setup_legacy_mocks(
context, plan_service_attrs={"list_plans": list_plans}
)
context._r2cov_cleanups.extend(patches)
# ======================================================================
# Given — Legacy continue mocks
# ======================================================================
@given("a mocked legacy container for r2cov continue that raises CleverAgentsError")
def step_mock_continue_clever_error(context: Context) -> None:
get_current = MagicMock(side_effect=CleverAgentsError("continue failed"))
patches = _setup_legacy_mocks(
context, plan_service_attrs={"get_current_plan": get_current}
)
context._r2cov_cleanups.extend(patches)
# ======================================================================
# Given — _get_lifecycle_service construction
# ======================================================================
@given("a mocked container for r2cov lifecycle service construction")
def step_mock_container_for_lifecycle(context: Context) -> None:
mock_container = MagicMock()
mock_settings = MagicMock()
mock_container.settings.return_value = mock_settings
mock_container.plan_lifecycle_service.return_value = MagicMock(
settings=mock_settings
)
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
context.r2cov_mock_settings = mock_settings
# ======================================================================
# Given — Multi-project scopes plan
# ======================================================================
@given("a lifecycle plan with multi-project scopes for r2cov")
def step_plan_with_multi_project_scopes(context: Context) -> None:
scopes = [
ProjectScope(
project_name="proj-alpha",
alias="alpha",
read_only=False,
changeset_summary=ChangeSetSummary(
project_name="proj-alpha",
files_changed=3,
files_added=1,
files_deleted=0,
total_lines_changed=42,
),
),
ProjectScope(
project_name="proj-beta",
alias=None,
read_only=True,
changeset_summary=None,
),
]
metadata = MultiProjectMetadata(project_scopes=scopes)
context.r2cov_lifecycle_plan = _make_lifecycle_plan(
multi_project_metadata=metadata,
)
@given("a lifecycle plan with multi-project scopes with validation for r2cov")
def step_plan_with_validation_scopes(context: Context) -> None:
scopes = [
ProjectScope(
project_name="proj-gamma",
changeset_summary=ChangeSetSummary(
project_name="proj-gamma",
files_changed=5,
files_added=2,
files_deleted=1,
total_lines_changed=100,
validation_passed=True,
),
),
]
metadata = MultiProjectMetadata(project_scopes=scopes)
context.r2cov_lifecycle_plan = _make_lifecycle_plan(
multi_project_metadata=metadata,
)
# ======================================================================
# Given — Lifecycle service for use_action
# ======================================================================
@given("a mocked lifecycle service for r2cov use action")
def step_mock_lifecycle_for_use(context: Context) -> None:
mock_service = MagicMock()
mock_action = MagicMock()
mock_action.namespaced_name = "local/test-action"
mock_service.get_action_by_name.return_value = mock_action
mock_plan = _make_lifecycle_plan()
mock_service.use_action.return_value = mock_plan
p = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p.start()
context._r2cov_cleanups.append(p.stop)
context.r2cov_service = mock_service
context.r2cov_plan = mock_plan
# ======================================================================
# Given — Lifecycle service for execute_plan
# ======================================================================
@given("a mocked lifecycle service for r2cov execute with a valid plan")
def step_mock_lifecycle_for_execute(context: Context) -> None:
mock_service = MagicMock()
plan = _make_lifecycle_plan(
processing_state=ProcessingState.COMPLETE,
)
mock_service.get_plan.return_value = plan
mock_service.execute_plan.return_value = plan
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
# ======================================================================
# Given — Resume service mocks
# ======================================================================
@given("a mocked resume service for r2cov with all optional fields")
def step_mock_resume_all_fields(context: Context) -> None:
summary = ResumeSummary(
plan_id="PLAN123",
phase="execute",
processing_state="errored",
last_completed_step=3,
next_step_index=4,
total_steps=10,
decision_id="DEC-001",
last_checkpoint_id="CP-001",
sandbox_ref="sandbox-abc-123",
)
mock_lifecycle = MagicMock()
p_lifecycle = patch(_PATCH_GET_LIFECYCLE, return_value=mock_lifecycle)
p_lifecycle.start()
context._r2cov_cleanups.append(p_lifecycle.stop)
p_resume = patch(
"cleveragents.application.services.plan_resume_service.PlanResumeService",
autospec=False,
)
mock_resume_cls = p_resume.start()
context._r2cov_cleanups.append(p_resume.stop)
mock_resume_instance = MagicMock()
mock_resume_instance.resume_plan.return_value = summary
mock_resume_cls.return_value = mock_resume_instance
@given("a mocked resume service for r2cov that raises CleverAgentsError")
def step_mock_resume_clever_error(context: Context) -> None:
mock_lifecycle = MagicMock()
p_lifecycle = patch(_PATCH_GET_LIFECYCLE, return_value=mock_lifecycle)
p_lifecycle.start()
context._r2cov_cleanups.append(p_lifecycle.stop)
p_resume = patch(
"cleveragents.application.services.plan_resume_service.PlanResumeService",
autospec=False,
)
mock_resume_cls = p_resume.start()
context._r2cov_cleanups.append(p_resume.stop)
mock_resume_instance = MagicMock()
mock_resume_instance.resume_plan.side_effect = CleverAgentsError("resume failed")
mock_resume_cls.return_value = mock_resume_instance
# ======================================================================
# Given — Checkpoint service for rollback
# ======================================================================
def _make_rollback_result() -> RollbackResult:
return RollbackResult(
restored_files_count=3,
changed_paths=["src/a.py", "src/b.py", "src/c.py"],
from_checkpoint_id=_ULID_CP,
)
@given("a mocked checkpoint service for r2cov rollback that succeeds")
def step_mock_rollback_success(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.return_value = _make_rollback_result()
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback that raises BusinessRuleViolation"
)
def step_mock_rollback_business_rule(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = BusinessRuleViolation(
"Plan is already applied"
)
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given(
"a mocked checkpoint service for r2cov rollback that raises ResourceNotFoundError"
)
def step_mock_rollback_not_found(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = ResourceNotFoundError(
"Checkpoint not found"
)
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given("a mocked checkpoint service for r2cov rollback that raises ValidationError")
def step_mock_rollback_validation(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = ValidationError("Invalid checkpoint")
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
@given("a mocked checkpoint service for r2cov rollback that raises CleverAgentsError")
def step_mock_rollback_clever_error(context: Context) -> None:
mock_container = MagicMock()
mock_cp_svc = MagicMock()
mock_cp_svc.selective_rollback.side_effect = CleverAgentsError(
"unknown rollback failure"
)
mock_container.checkpoint_service.return_value = mock_cp_svc
p = patch(_PATCH_CONTAINER, return_value=mock_container)
p.start()
context._r2cov_cleanups.append(p.stop)
# ======================================================================
# When — Legacy commands
# ======================================================================
@when("I invoke r2cov legacy build")
def step_invoke_legacy_build(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(plan_app, ["build"])
@when("I invoke r2cov legacy apply with --yes")
def step_invoke_legacy_apply_yes(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(plan_app, ["apply", "--yes"])
@when('I invoke r2cov legacy new "{name}"')
def step_invoke_legacy_new(context: Context, name: str) -> None:
context.r2cov_result = context.r2cov_runner.invoke(plan_app, ["new", name])
@when("I invoke r2cov legacy current")
def step_invoke_legacy_current(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(plan_app, ["current"])
@when("I invoke r2cov legacy list")
def step_invoke_legacy_list(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(plan_app, ["list"])
@when("I invoke r2cov legacy continue")
def step_invoke_legacy_continue(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(plan_app, ["continue"])
# ======================================================================
# When — _get_lifecycle_service
# ======================================================================
@when("I call r2cov _get_lifecycle_service")
def step_call_get_lifecycle_service(context: Context) -> None:
from cleveragents.cli.commands.plan import _get_lifecycle_service
context.r2cov_lifecycle_result = _get_lifecycle_service()
# ======================================================================
# When — _print_lifecycle_plan
# ======================================================================
@when("I invoke r2cov _print_lifecycle_plan on the plan")
def step_invoke_print_lifecycle_plan(context: Context) -> None:
from cleveragents.cli.commands.plan import _print_lifecycle_plan
# Capture console output
buf = io.StringIO()
capture_console = Console(file=buf, width=200, force_terminal=True)
original = plan_module.console
plan_module.console = capture_console
try:
_print_lifecycle_plan(context.r2cov_lifecycle_plan, title="Test Plan")
finally:
plan_module.console = original
context.r2cov_result = SimpleNamespace(
exit_code=0,
output=buf.getvalue(),
)
# ======================================================================
# When — use_action with execution_environment
# ======================================================================
@when('I invoke r2cov use with execution-environment "{env}"')
def step_invoke_use_exec_env(context: Context, env: str) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["use", "local/test-action", "--execution-environment", env],
)
# ======================================================================
# When — execute_plan with execution_environment
# ======================================================================
@when('I invoke r2cov execute with execution-environment "{env}"')
def step_invoke_execute_exec_env(context: Context, env: str) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["execute", _ULID_A, "--execution-environment", env],
)
# ======================================================================
# When — resume plan
# ======================================================================
@when('I invoke r2cov plan resume "{plan_id}" in rich format')
def step_invoke_resume_rich(context: Context, plan_id: str) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["resume", plan_id],
)
# ======================================================================
# When — rollback plan
# ======================================================================
@when("I invoke r2cov plan rollback with --yes")
def step_invoke_rollback_yes(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["rollback", "--yes", _ULID_A, _ULID_CP],
)
@when("I invoke r2cov plan rollback with --yes and --format json")
def step_invoke_rollback_yes_json(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["rollback", "--yes", "--format", "json", _ULID_A, _ULID_CP],
)
@when("I invoke r2cov plan rollback without --yes and decline")
def step_invoke_rollback_decline(context: Context) -> None:
context.r2cov_result = context.r2cov_runner.invoke(
plan_app,
["rollback", _ULID_A, _ULID_CP],
input="n\n",
)
# ======================================================================
# Then — common assertions
# ======================================================================
@then("the r2cov command should exit normally")
def step_exit_normally(context: Context) -> None:
result = context.r2cov_result
assert result is not None, "No command result captured"
assert result.exit_code == 0, (
f"Expected exit code 0, got {result.exit_code}.\nOutput:\n{result.output}"
)
@then("the r2cov command should abort")
def step_exit_abort(context: Context) -> None:
result = context.r2cov_result
assert result is not None, "No command result captured"
assert result.exit_code != 0, (
f"Expected non-zero exit code, got {result.exit_code}.\n"
f"Output:\n{result.output}"
)
@then('the r2cov output should contain "{text}"')
def step_output_contains(context: Context, text: str) -> None:
result = context.r2cov_result
assert result is not None, "No command result captured"
assert text in result.output, (
f"Expected '{text}' in output.\nActual output:\n{result.output}"
)
@then("the r2cov lifecycle service should be returned")
def step_lifecycle_returned(context: Context) -> None:
assert context.r2cov_lifecycle_result is not None, (
"Expected a lifecycle service instance"
)