forked from HAL9000/cleveragents-core
4ca4874c4d
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
455 lines
17 KiB
Python
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)}"
|
|
)
|