4ca4874c4d
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 34s
CI / quality (pull_request) Successful in 15s
CI / security (pull_request) Successful in 35s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 2m54s
CI / unit_tests (pull_request) Successful in 4m9s
CI / coverage (pull_request) Successful in 4m48s
CI / docker (pull_request) Successful in 1m46s
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 35s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 31s
CI / build (push) Successful in 14s
CI / unit_tests (push) Successful in 2m11s
CI / integration_tests (push) Successful in 3m9s
CI / benchmark-regression (pull_request) Successful in 30m12s
CI / benchmark-regression (push) Has been skipped
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 4m25s
CI / benchmark-publish (push) Successful in 17m34s
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
218 lines
6.7 KiB
Python
218 lines
6.7 KiB
Python
"""Helper script for cross-plan correction Robot Framework smoke tests.
|
|
|
|
Exercises CrossPlanCorrectionService cascade flows, rejection logic,
|
|
and atomic rollback without requiring persistence infrastructure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from cleveragents.application.services.cross_plan_correction_service import (
|
|
CrossPlanCorrectionService,
|
|
)
|
|
from cleveragents.domain.models.core.correction import (
|
|
ChildPlanState,
|
|
CorrectionImpact,
|
|
CorrectionMode,
|
|
CorrectionRejection,
|
|
CorrectionResult,
|
|
)
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock implementations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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:
|
|
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)
|
|
|
|
|
|
def _make_service(
|
|
states: dict[str, ChildPlanState],
|
|
fail_on: str | None = None,
|
|
) -> tuple[
|
|
CrossPlanCorrectionService, MockPlanLookup, MockPlanCanceller, MockSandboxRollbacker
|
|
]:
|
|
lookup = MockPlanLookup()
|
|
for pid, s in states.items():
|
|
lookup.set_state(pid, s)
|
|
canceller = MockPlanCanceller(fail_on=fail_on)
|
|
rollbacker = MockSandboxRollbacker()
|
|
svc = CrossPlanCorrectionService(
|
|
plan_lookup=lookup,
|
|
plan_canceller=canceller,
|
|
sandbox_rollbacker=rollbacker,
|
|
)
|
|
return svc, lookup, canceller, rollbacker
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _cancel_not_started() -> None:
|
|
svc, _, _canceller, _rollbacker = _make_service({"CP1": ChildPlanState.NOT_STARTED})
|
|
result = svc.execute_cascade("C1", ["CP1"])
|
|
assert not result.rejected
|
|
assert "CP1" in result.all_cancelled_plan_ids
|
|
assert len(result.all_rolled_back_plan_ids) == 0
|
|
print("cancel-not-started-ok")
|
|
|
|
|
|
def _cancel_rollback_in_progress() -> None:
|
|
svc, _, _canceller, _rollbacker = _make_service({"CP1": ChildPlanState.IN_PROGRESS})
|
|
result = svc.execute_cascade("C1", ["CP1"])
|
|
assert not result.rejected
|
|
assert "CP1" in result.all_cancelled_plan_ids
|
|
assert "CP1" in result.all_rolled_back_plan_ids
|
|
print("cancel-rollback-in-progress-ok")
|
|
|
|
|
|
def _cancel_rollback_completed() -> None:
|
|
svc, _, _, _ = _make_service({"CP1": ChildPlanState.COMPLETED_UNAPPLIED})
|
|
result = svc.execute_cascade("C1", ["CP1"])
|
|
assert not result.rejected
|
|
assert "CP1" in result.all_cancelled_plan_ids
|
|
assert "CP1" in result.all_rolled_back_plan_ids
|
|
print("cancel-rollback-completed-ok")
|
|
|
|
|
|
def _reject_applied() -> None:
|
|
svc, _, _, _ = _make_service({"CP1": ChildPlanState.APPLIED})
|
|
result = svc.execute_cascade("C1", ["CP1"])
|
|
assert result.rejected
|
|
assert result.rejection is not None
|
|
assert "CP1" in result.rejection.affected_applied_child_plan_ids
|
|
print("reject-applied-ok")
|
|
|
|
|
|
def _mixed_states_reject() -> None:
|
|
svc, _, _, _ = _make_service(
|
|
{
|
|
"CP1": ChildPlanState.NOT_STARTED,
|
|
"CP2": ChildPlanState.IN_PROGRESS,
|
|
"CP3": ChildPlanState.APPLIED,
|
|
}
|
|
)
|
|
result = svc.execute_cascade("C1", ["CP1", "CP2", "CP3"])
|
|
assert result.rejected
|
|
assert result.rejection is not None
|
|
assert "CP3" in result.rejection.affected_applied_child_plan_ids
|
|
assert len(result.all_cancelled_plan_ids) == 0
|
|
print("mixed-states-reject-ok")
|
|
|
|
|
|
def _atomic_failure() -> None:
|
|
svc, _, _, _ = _make_service(
|
|
{"CP1": ChildPlanState.NOT_STARTED, "CP2": ChildPlanState.NOT_STARTED},
|
|
fail_on="CP2",
|
|
)
|
|
try:
|
|
svc.execute_cascade("C1", ["CP1", "CP2"])
|
|
raise AssertionError("Expected RuntimeError")
|
|
except RuntimeError as exc:
|
|
assert "CP2" in str(exc)
|
|
print("atomic-failure-ok")
|
|
|
|
|
|
def _cascade_no_children() -> None:
|
|
svc, _, _, _ = _make_service({})
|
|
impact = CorrectionImpact(
|
|
affected_decisions=["D1"],
|
|
affected_child_plans=[],
|
|
risk_level="low",
|
|
rollback_tier="full",
|
|
artifacts_to_archive=["D1.artifact"],
|
|
)
|
|
result = svc.execute_correction_with_cascade("C1", impact, CorrectionMode.REVERT)
|
|
assert isinstance(result, CorrectionResult)
|
|
assert result.status == "applied"
|
|
print("cascade-no-children-ok")
|
|
|
|
|
|
def _cascade_rejection() -> None:
|
|
svc, _, _, _ = _make_service({"CP1": ChildPlanState.APPLIED})
|
|
impact = CorrectionImpact(
|
|
affected_decisions=["D1"],
|
|
affected_child_plans=["CP1"],
|
|
risk_level="low",
|
|
rollback_tier="full",
|
|
artifacts_to_archive=["D1.artifact"],
|
|
)
|
|
result = svc.execute_correction_with_cascade("C1", impact, CorrectionMode.REVERT)
|
|
assert isinstance(result, CorrectionRejection)
|
|
print("cascade-rejection-ok")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_COMMANDS = {
|
|
"cancel-not-started": _cancel_not_started,
|
|
"cancel-rollback-in-progress": _cancel_rollback_in_progress,
|
|
"cancel-rollback-completed": _cancel_rollback_completed,
|
|
"reject-applied": _reject_applied,
|
|
"mixed-states-reject": _mixed_states_reject,
|
|
"atomic-failure": _atomic_failure,
|
|
"cascade-no-children": _cascade_no_children,
|
|
"cascade-rejection": _cascade_rejection,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
"""Dispatch to the requested command."""
|
|
if len(sys.argv) < 2:
|
|
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
command = sys.argv[1]
|
|
handler = _COMMANDS.get(command)
|
|
if handler is None:
|
|
print(f"Unknown command: {command}", file=sys.stderr)
|
|
print(f"Available: {', '.join(sorted(_COMMANDS))}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
handler()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|