Files
temp/features/steps/cross_plan_correction_steps.py
freemo 4ca4874c4d feat(correction): implement cross-plan correction cascading with child plan state handling
Add CrossPlanCorrectionService that implements the four child-plan-state-
dependent behaviours from the specification when a correction's affected
subtree includes child plans:

- Not yet started → cancel the child plan
- In progress → cancel + rollback sandbox to pre-child-plan state
- Completed but not applied → cancel + rollback sandbox
- Already applied → reject the correction (CorrectionRejection)

Key additions:
- ChildPlanState enum classifying child plans into 4 states
- CorrectionRejection result type with reason and affected applied plan IDs
- CascadeAction/CascadeResult models for cascade operation tracking
- CorrectionStatus.REJECTED for rejected corrections
- Atomic cascade-or-rollback: all child plan actions succeed or the
  entire cascade is rolled back
- Protocol-based dependency injection (ChildPlanLookup, ChildPlanCanceller,
  SandboxRollbacker) for testability
- execute_correction_with_cascade() integrates with CorrectionService flow

Testing:
- 24 Behave BDD scenarios in cross_plan_correction.feature
- 8 Robot Framework end-to-end smoke tests
- ASV benchmarks for cascade performance with varying child plan counts

ISSUES CLOSED: #547
2026-03-04 21:20:47 +00:00

455 lines
17 KiB
Python

"""Step definitions for cross_plan_correction.feature.
Exercises CrossPlanCorrectionService cascade flows, rejection logic,
atomic rollback, model validation, and helper function behaviour.
"""
from __future__ import annotations
from behave import given, then, when
from pydantic import ValidationError as PydanticValidationError
from cleveragents.application.services.cross_plan_correction_service import (
CrossPlanCorrectionService,
classify_cascade_action,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.correction import (
CascadeAction,
CascadeResult,
ChildPlanState,
CorrectionImpact,
CorrectionMode,
CorrectionRejection,
CorrectionResult,
CorrectionStatus,
)
# -------------------------------------------------------------------
# Mock implementations (in-step, minimal — no external mocks needed)
# -------------------------------------------------------------------
class _MockPlanLookup:
"""In-memory child plan state lookup."""
def __init__(self) -> None:
self._states: dict[str, ChildPlanState] = {}
def set_state(self, plan_id: str, state: ChildPlanState) -> None:
self._states[plan_id] = state
def get_child_plan_state(self, child_plan_id: str) -> ChildPlanState:
if child_plan_id not in self._states:
raise KeyError(f"Unknown child plan: {child_plan_id}")
return self._states[child_plan_id]
class _MockPlanCanceller:
"""In-memory child plan canceller."""
def __init__(self, fail_on: str | None = None) -> None:
self.cancelled: list[str] = []
self._fail_on = fail_on
def cancel_child_plan(self, child_plan_id: str) -> None:
if self._fail_on and child_plan_id == self._fail_on:
raise RuntimeError(f"Cancel failed for {child_plan_id}")
self.cancelled.append(child_plan_id)
class _MockSandboxRollbacker:
"""In-memory sandbox rollbacker."""
def __init__(self) -> None:
self.rolled_back: list[str] = []
def rollback_child_plan_sandbox(self, child_plan_id: str) -> None:
self.rolled_back.append(child_plan_id)
# -------------------------------------------------------------------
# Given steps
# -------------------------------------------------------------------
@given("a cross-plan correction service")
def step_create_cross_plan_service(context: object) -> None:
lookup = _MockPlanLookup()
canceller = _MockPlanCanceller()
rollbacker = _MockSandboxRollbacker()
context.lookup = lookup # type: ignore[attr-defined]
context.canceller = canceller # type: ignore[attr-defined]
context.rollbacker = rollbacker # type: ignore[attr-defined]
context.service = CrossPlanCorrectionService( # type: ignore[attr-defined]
plan_lookup=lookup,
plan_canceller=canceller,
sandbox_rollbacker=rollbacker,
)
context.cascade_result = None # type: ignore[attr-defined]
context.cascade_error = None # type: ignore[attr-defined]
context.error = None # type: ignore[attr-defined]
context.correction_with_cascade_result = None # type: ignore[attr-defined]
context.classified_action = None # type: ignore[attr-defined]
@given('a cross-plan correction service with failing canceller on "{fail_id}"')
def step_create_service_failing_canceller(context: object, fail_id: str) -> None:
lookup = _MockPlanLookup()
canceller = _MockPlanCanceller(fail_on=fail_id)
rollbacker = _MockSandboxRollbacker()
context.lookup = lookup # type: ignore[attr-defined]
context.canceller = canceller # type: ignore[attr-defined]
context.rollbacker = rollbacker # type: ignore[attr-defined]
context.service = CrossPlanCorrectionService( # type: ignore[attr-defined]
plan_lookup=lookup,
plan_canceller=canceller,
sandbox_rollbacker=rollbacker,
)
context.cascade_result = None # type: ignore[attr-defined]
context.cascade_error = None # type: ignore[attr-defined]
context.error = None # type: ignore[attr-defined]
@given('the child plan "{plan_id}" has state "{state}"')
def step_set_child_plan_state(context: object, plan_id: str, state: str) -> None:
context.lookup.set_state(plan_id, ChildPlanState(state)) # type: ignore[attr-defined]
# -------------------------------------------------------------------
# When steps
# -------------------------------------------------------------------
@when(
'I execute a cascade correction for correction "{cid}" with child plans "{plans}"'
)
def step_execute_cascade(context: object, cid: str, plans: str) -> None:
plan_ids = [p.strip() for p in plans.split(",")]
context.cascade_result = context.service.execute_cascade(cid, plan_ids) # type: ignore[attr-defined]
@when(
'I try to execute a cascade correction for correction "{cid}" with child plans "{plans}"'
)
def step_try_execute_cascade(context: object, cid: str, plans: str) -> None:
plan_ids = [p.strip() for p in plans.split(",")]
try:
context.cascade_result = context.service.execute_cascade(cid, plan_ids) # type: ignore[attr-defined]
context.cascade_error = None # type: ignore[attr-defined]
except Exception as exc:
context.cascade_error = exc # type: ignore[attr-defined]
@when('I evaluate a cascade for correction "{cid}" with no child plans')
def step_evaluate_cascade_empty(context: object, cid: str) -> None:
context.cascade_result = context.service.evaluate_cascade(cid, []) # type: ignore[attr-defined]
@when('I evaluate a cascade for correction "{cid}" with child plans "{plans}"')
def step_evaluate_cascade(context: object, cid: str, plans: str) -> None:
plan_ids = [p.strip() for p in plans.split(",")]
context.cascade_result = context.service.evaluate_cascade(cid, plan_ids) # type: ignore[attr-defined]
@when("I try to evaluate a cascade with empty correction_id")
def step_evaluate_empty_cid(context: object) -> None:
try:
context.service.evaluate_cascade("", []) # type: ignore[attr-defined]
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when('I classify cascade action for state "{state}"')
def step_classify_action(context: object, state: str) -> None:
context.classified_action = classify_cascade_action(ChildPlanState(state)) # type: ignore[attr-defined]
@when("I try to create a cross-plan service with None plan_lookup")
def step_create_none_lookup(context: object) -> None:
try:
CrossPlanCorrectionService(
plan_lookup=None, # type: ignore[arg-type]
plan_canceller=_MockPlanCanceller(),
sandbox_rollbacker=_MockSandboxRollbacker(),
)
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when("I try to create a cross-plan service with None plan_canceller")
def step_create_none_canceller(context: object) -> None:
try:
CrossPlanCorrectionService(
plan_lookup=_MockPlanLookup(),
plan_canceller=None, # type: ignore[arg-type]
sandbox_rollbacker=_MockSandboxRollbacker(),
)
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when("I try to create a cross-plan service with None sandbox_rollbacker")
def step_create_none_rollbacker(context: object) -> None:
try:
CrossPlanCorrectionService(
plan_lookup=_MockPlanLookup(),
plan_canceller=_MockPlanCanceller(),
sandbox_rollbacker=None, # type: ignore[arg-type]
)
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when(
'I create a CorrectionRejection with correction_id "{cid}" '
'and reason "{reason}" and plans "{plans}"'
)
def step_create_rejection_model(
context: object, cid: str, reason: str, plans: str
) -> None:
plan_ids = [p.strip() for p in plans.split(",")]
context.rejection = CorrectionRejection( # type: ignore[attr-defined]
correction_id=cid,
reason=reason,
affected_applied_child_plan_ids=plan_ids,
)
@when('I try to create a CascadeAction with invalid action "{action}"')
def step_create_bad_cascade_action(context: object, action: str) -> None:
try:
CascadeAction(
child_plan_id="CP1",
child_plan_state=ChildPlanState.NOT_STARTED,
action=action,
)
context.error = None # type: ignore[attr-defined]
except PydanticValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when(
'I execute a correction with cascade for correction "{cid}" '
"with no child plans in revert mode"
)
def step_execute_correction_cascade_no_children(context: object, cid: str) -> None:
impact = CorrectionImpact(
affected_decisions=["D1", "D2"],
affected_child_plans=[],
risk_level="low",
rollback_tier="full",
artifacts_to_archive=["D1.artifact", "D2.artifact"],
)
context.correction_with_cascade_result = ( # type: ignore[attr-defined]
context.service.execute_correction_with_cascade( # type: ignore[attr-defined]
cid, impact, CorrectionMode.REVERT
)
)
@when(
'I execute a correction with cascade for correction "{cid}" '
'with child plan "{plan_id}" in revert mode'
)
def step_execute_correction_cascade_with_child(
context: object, cid: str, plan_id: str
) -> None:
impact = CorrectionImpact(
affected_decisions=["D1"],
affected_child_plans=[plan_id],
risk_level="low",
rollback_tier="full",
artifacts_to_archive=["D1.artifact"],
)
context.correction_with_cascade_result = ( # type: ignore[attr-defined]
context.service.execute_correction_with_cascade( # type: ignore[attr-defined]
cid, impact, CorrectionMode.REVERT
)
)
# -------------------------------------------------------------------
# Then steps
# -------------------------------------------------------------------
@then("the cascade should succeed")
def step_cascade_success(context: object) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert result is not None, "No cascade result"
assert not result.rejected, f"Cascade was rejected: {result.rejection}"
@then("the cascade should be rejected")
def step_cascade_rejected(context: object) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert result is not None, "No cascade result"
assert result.rejected, "Cascade was not rejected"
@then('the cancelled child plans should contain "{plan_id}"')
def step_cancelled_contains(context: object, plan_id: str) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert plan_id in result.all_cancelled_plan_ids, (
f"'{plan_id}' not in cancelled: {result.all_cancelled_plan_ids}"
)
@then("the cancelled child plans should be empty")
def step_cancelled_empty(context: object) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert len(result.all_cancelled_plan_ids) == 0, (
f"Expected empty cancelled list, got {result.all_cancelled_plan_ids}"
)
@then('the rolled back child plans should contain "{plan_id}"')
def step_rolled_back_contains(context: object, plan_id: str) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert plan_id in result.all_rolled_back_plan_ids, (
f"'{plan_id}' not in rolled back: {result.all_rolled_back_plan_ids}"
)
@then("the rolled back child plans should be empty")
def step_rolled_back_empty(context: object) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert len(result.all_rolled_back_plan_ids) == 0, (
f"Expected empty rolled back list, got {result.all_rolled_back_plan_ids}"
)
@then('the rejection reason should mention "{fragment}"')
def step_rejection_reason_contains(context: object, fragment: str) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert result.rejection is not None, "No rejection"
assert fragment in result.rejection.reason, (
f"'{fragment}' not in reason: {result.rejection.reason}"
)
@then('the rejection should list applied child plan "{plan_id}"')
def step_rejection_lists_plan(context: object, plan_id: str) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert result.rejection is not None, "No rejection"
assert plan_id in result.rejection.affected_applied_child_plan_ids, (
f"'{plan_id}' not in applied IDs: "
f"{result.rejection.affected_applied_child_plan_ids}"
)
@then("the cascade should raise an error")
def step_cascade_error(context: object) -> None:
assert context.cascade_error is not None, "Expected error but none raised" # type: ignore[attr-defined]
@then('the cascade error should mention "{fragment}"')
def step_cascade_error_message(context: object, fragment: str) -> None:
assert context.cascade_error is not None, "No error" # type: ignore[attr-defined]
assert fragment in str(context.cascade_error), ( # type: ignore[attr-defined]
f"'{fragment}' not in error: {context.cascade_error}" # type: ignore[attr-defined]
)
@then("the cascade evaluation should succeed")
def step_evaluation_success(context: object) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert result is not None, "No cascade result"
@then("the cascade actions should be empty")
def step_actions_empty(context: object) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
assert len(result.cascade_actions) == 0, (
f"Expected empty actions, got {result.cascade_actions}"
)
@then('the cascade action for "{plan_id}" should be "{action}"')
def step_action_for_plan(context: object, plan_id: str, action: str) -> None:
result: CascadeResult = context.cascade_result # type: ignore[attr-defined]
for ca in result.cascade_actions:
if ca.child_plan_id == plan_id:
assert ca.action == action, (
f"Expected action '{action}' for {plan_id}, got '{ca.action}'"
)
return
raise AssertionError(f"No action found for plan '{plan_id}'")
@then("a cross-plan validation error should be raised")
def step_cross_plan_validation_error(context: object) -> None:
assert isinstance(context.error, ValidationError), ( # type: ignore[attr-defined]
f"Expected ValidationError, got {type(context.error)}" # type: ignore[attr-defined]
)
@then('the classified action should be "{expected}"')
def step_classified_action(context: object, expected: str) -> None:
assert context.classified_action == expected, ( # type: ignore[attr-defined]
f"Expected '{expected}', got '{context.classified_action}'" # type: ignore[attr-defined]
)
@then('the rejection correction_id should be "{cid}"')
def step_rejection_cid(context: object, cid: str) -> None:
assert context.rejection.correction_id == cid # type: ignore[attr-defined]
@then('the rejection reason should be "{reason}"')
def step_rejection_reason(context: object, reason: str) -> None:
assert context.rejection.reason == reason # type: ignore[attr-defined]
@then('the rejection affected plans should contain "{plan_id}"')
def step_rejection_plans_contain(context: object, plan_id: str) -> None:
assert plan_id in context.rejection.affected_applied_child_plan_ids # type: ignore[attr-defined]
@then("a cross-plan pydantic validation error should be raised")
def step_pydantic_error(context: object) -> None:
assert isinstance(context.error, PydanticValidationError), ( # type: ignore[attr-defined]
f"Expected PydanticValidationError, got {type(context.error)}" # type: ignore[attr-defined]
)
@then('the CorrectionStatus enum should include "{value}"')
def step_status_includes(context: object, value: str) -> None:
values = [s.value for s in CorrectionStatus]
assert value in values, f"'{value}' not in CorrectionStatus: {values}"
@then("the ChildPlanState enum should have {count:d} members")
def step_child_state_count(context: object, count: int) -> None:
assert len(ChildPlanState) == count, (
f"Expected {count} members, got {len(ChildPlanState)}"
)
@then('the ChildPlanState enum should include "{value}"')
def step_child_state_includes(context: object, value: str) -> None:
values = [s.value for s in ChildPlanState]
assert value in values, f"'{value}' not in ChildPlanState: {values}"
@then("the correction with cascade result should be applied")
def step_correction_cascade_applied(context: object) -> None:
result = context.correction_with_cascade_result # type: ignore[attr-defined]
assert isinstance(result, CorrectionResult), (
f"Expected CorrectionResult, got {type(result)}"
)
assert result.status == CorrectionStatus.APPLIED
@then("the correction with cascade result should be a rejection")
def step_correction_cascade_rejection(context: object) -> None:
result = context.correction_with_cascade_result # type: ignore[attr-defined]
assert isinstance(result, CorrectionRejection), (
f"Expected CorrectionRejection, got {type(result)}"
)