feat(correction): implement cross-plan correction cascading with child plan state handling #562

Merged
freemo merged 1 commits from feature/m6-cross-plan-correction into master 2026-03-04 22:10:15 +00:00
9 changed files with 1952 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
"""ASV benchmarks for cross-plan correction cascading performance.
Measures cascade throughput with varying numbers of child plans in
different states: cancel-only, cancel-with-rollback, and mixed states.
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.application.services.cross_plan_correction_service import ( # noqa: E402
CrossPlanCorrectionService,
)
from cleveragents.domain.models.core.correction import ( # noqa: E402
ChildPlanState,
CorrectionImpact,
CorrectionMode,
)
# ---------------------------------------------------------------------------
# Mock implementations (minimal, for benchmarking)
# ---------------------------------------------------------------------------
class _BenchPlanLookup:
def __init__(self, states: dict[str, ChildPlanState]) -> None:
self._states = states
def get_child_plan_state(self, child_plan_id: str) -> ChildPlanState:
return self._states[child_plan_id]
class _BenchPlanCanceller:
def cancel_child_plan(self, child_plan_id: str) -> None:
pass
class _BenchSandboxRollbacker:
def rollback_child_plan_sandbox(self, child_plan_id: str) -> None:
pass
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_service(
child_count: int, state: ChildPlanState
) -> tuple[CrossPlanCorrectionService, list[str]]:
plan_ids = [f"CP{i}" for i in range(child_count)]
states = {pid: state for pid in plan_ids}
lookup = _BenchPlanLookup(states)
canceller = _BenchPlanCanceller()
rollbacker = _BenchSandboxRollbacker()
svc = CrossPlanCorrectionService(
plan_lookup=lookup,
plan_canceller=canceller,
sandbox_rollbacker=rollbacker,
)
return svc, plan_ids
# ---------------------------------------------------------------------------
# Benchmark suites
# ---------------------------------------------------------------------------
class CascadeCancelSuite:
"""Benchmark cascade with cancel-only (not-started) child plans."""
params = [1, 5, 10, 50, 100]
param_names = ["child_plan_count"]
def setup(self, child_plan_count: int) -> None:
self.svc, self.plan_ids = _make_service(
child_plan_count, ChildPlanState.NOT_STARTED
)
def time_execute_cascade_cancel(self, child_plan_count: int) -> None:
self.svc.execute_cascade("C1", self.plan_ids)
def time_evaluate_cascade_cancel(self, child_plan_count: int) -> None:
self.svc.evaluate_cascade("C1", self.plan_ids)
class CascadeRollbackSuite:
"""Benchmark cascade with cancel+rollback (in-progress) child plans."""
params = [1, 5, 10, 50, 100]
param_names = ["child_plan_count"]
def setup(self, child_plan_count: int) -> None:
self.svc, self.plan_ids = _make_service(
child_plan_count, ChildPlanState.IN_PROGRESS
)
def time_execute_cascade_rollback(self, child_plan_count: int) -> None:
self.svc.execute_cascade("C1", self.plan_ids)
class CascadeRejectionSuite:
"""Benchmark cascade rejection (applied child plans)."""
params = [1, 5, 10, 50, 100]
param_names = ["child_plan_count"]
def setup(self, child_plan_count: int) -> None:
self.svc, self.plan_ids = _make_service(
child_plan_count, ChildPlanState.APPLIED
)
def time_execute_cascade_rejection(self, child_plan_count: int) -> None:
self.svc.execute_cascade("C1", self.plan_ids)
class CorrectionWithCascadeSuite:
"""Benchmark full execute_correction_with_cascade flow."""
params = [0, 5, 20, 50]
param_names = ["child_plan_count"]
def setup(self, child_plan_count: int) -> None:
plan_ids = [f"CP{i}" for i in range(child_plan_count)]
states = {pid: ChildPlanState.NOT_STARTED for pid in plan_ids}
lookup = _BenchPlanLookup(states)
canceller = _BenchPlanCanceller()
rollbacker = _BenchSandboxRollbacker()
self.svc = CrossPlanCorrectionService(
plan_lookup=lookup,
plan_canceller=canceller,
sandbox_rollbacker=rollbacker,
)
self.impact = CorrectionImpact(
affected_decisions=["D1"],
affected_child_plans=plan_ids,
risk_level="low",
rollback_tier="full",
artifacts_to_archive=["D1.artifact"],
)
def time_correction_with_cascade(self, child_plan_count: int) -> None:
self.svc.execute_correction_with_cascade(
"C1", self.impact, CorrectionMode.REVERT
)
+303
View File
@@ -0,0 +1,303 @@
Feature: Cross-plan correction cascading
When a correction's affected subtree includes child plans, the child
plan's state determines the cascade behaviour: cancel, cancel with
sandbox rollback, or reject the correction entirely.
#
# Not-yet-started child plans cancel only
#
Scenario: Correction cascades to not-started child plan — cancel
Given a cross-plan correction service
And the child plan "CP1" has state "not_started"
When I execute a cascade correction for correction "C1" with child plans "CP1"
Then the cascade should succeed
And the cancelled child plans should contain "CP1"
And the rolled back child plans should be empty
# ─────────────────────────────────────────────────────────────────
# In-progress child plans → cancel + sandbox rollback
# ─────────────────────────────────────────────────────────────────
Scenario: Correction cascades to in-progress child plan — cancel and rollback
Given a cross-plan correction service
And the child plan "CP1" has state "in_progress"
When I execute a cascade correction for correction "C1" with child plans "CP1"
Then the cascade should succeed
And the cancelled child plans should contain "CP1"
And the rolled back child plans should contain "CP1"
# ─────────────────────────────────────────────────────────────────
# Completed-unapplied child plans → cancel + rollback
# ─────────────────────────────────────────────────────────────────
Scenario: Correction cascades to completed-unapplied child plan — cancel and rollback
Given a cross-plan correction service
And the child plan "CP1" has state "completed_unapplied"
When I execute a cascade correction for correction "C1" with child plans "CP1"
Then the cascade should succeed
And the cancelled child plans should contain "CP1"
And the rolled back child plans should contain "CP1"
# ─────────────────────────────────────────────────────────────────
# Already-applied child plans → rejection
# ─────────────────────────────────────────────────────────────────
Scenario: Correction cascades to already-applied child plan — rejection with CorrectionRejection
Given a cross-plan correction service
And the child plan "CP1" has state "applied"
When I execute a cascade correction for correction "C1" with child plans "CP1"
Then the cascade should be rejected
And the rejection reason should mention "already applied"
And the rejection should list applied child plan "CP1"
# ─────────────────────────────────────────────────────────────────
# Mixed child plan states
# ─────────────────────────────────────────────────────────────────
Scenario: Mixed child plan states — reject if any applied even if others are cancellable
Given a cross-plan correction service
And the child plan "CP1" has state "not_started"
And the child plan "CP2" has state "in_progress"
And the child plan "CP3" has state "applied"
When I execute a cascade correction for correction "C1" with child plans "CP1,CP2,CP3"
Then the cascade should be rejected
And the rejection should list applied child plan "CP3"
And the cancelled child plans should be empty
# ─────────────────────────────────────────────────────────────────
# Atomic cascade: failure during one cancel rolls back all previous
# ─────────────────────────────────────────────────────────────────
Scenario: Atomic cascade — failure during one cancel rolls back all previous cancels
Given a cross-plan correction service with failing canceller on "CP2"
And the child plan "CP1" has state "not_started"
And the child plan "CP2" has state "not_started"
When I try to execute a cascade correction for correction "C1" with child plans "CP1,CP2"
Then the cascade should raise an error
And the cascade error should mention "CP2"
# ─────────────────────────────────────────────────────────────────
# Evaluation (dry-run) scenarios
# ─────────────────────────────────────────────────────────────────
Scenario: Evaluate cascade without executing — no child plans
Given a cross-plan correction service
When I evaluate a cascade for correction "C1" with no child plans
Then the cascade evaluation should succeed
And the cascade actions should be empty
Scenario: Evaluate cascade — mixed states preview
Given a cross-plan correction service
And the child plan "CP1" has state "not_started"
And the child plan "CP2" has state "in_progress"
When I evaluate a cascade for correction "C1" with child plans "CP1,CP2"
Then the cascade evaluation should succeed
And the cascade action for "CP1" should be "cancel"
And the cascade action for "CP2" should be "cancel_and_rollback"
# ─────────────────────────────────────────────────────────────────
# Validation
# ─────────────────────────────────────────────────────────────────
Scenario: Empty correction_id raises validation error
Given a cross-plan correction service
When I try to evaluate a cascade with empty correction_id
Then a cross-plan validation error should be raised
# ─────────────────────────────────────────────────────────────────
# Multiple not-started child plans — all cancelled
# ─────────────────────────────────────────────────────────────────
Scenario: Multiple not-started child plans all cancelled
Given a cross-plan correction service
And the child plan "CP1" has state "not_started"
And the child plan "CP2" has state "not_started"
And the child plan "CP3" has state "not_started"
When I execute a cascade correction for correction "C1" with child plans "CP1,CP2,CP3"
Then the cascade should succeed
And the cancelled child plans should contain "CP1"
And the cancelled child plans should contain "CP2"
And the cancelled child plans should contain "CP3"
And the rolled back child plans should be empty
# ─────────────────────────────────────────────────────────────────
# Integration with CorrectionImpact — execute_correction_with_cascade
# ─────────────────────────────────────────────────────────────────
Scenario: Execute correction with cascade — no child plans returns result
Given a cross-plan correction service
When I execute a correction with cascade for correction "C1" with no child plans in revert mode
Then the correction with cascade result should be applied
Scenario: Execute correction with cascade — applied child plan returns rejection
Given a cross-plan correction service
And the child plan "CP1" has state "applied"
When I execute a correction with cascade for correction "C1" with child plan "CP1" in revert mode
Then the correction with cascade result should be a rejection
Scenario: Execute correction with cascade — cancellable child plan succeeds
Given a cross-plan correction service
And the child plan "CP1" has state "not_started"
When I execute a correction with cascade for correction "C1" with child plan "CP1" in revert mode
Then the correction with cascade result should be applied
# ─────────────────────────────────────────────────────────────────
# classify_cascade_action unit-level checks
# ─────────────────────────────────────────────────────────────────
Scenario: classify_cascade_action for not_started returns cancel
When I classify cascade action for state "not_started"
Then the classified action should be "cancel"
Scenario: classify_cascade_action for in_progress returns cancel_and_rollback
When I classify cascade action for state "in_progress"
Then the classified action should be "cancel_and_rollback"
Scenario: classify_cascade_action for completed_unapplied returns cancel_and_rollback
When I classify cascade action for state "completed_unapplied"
Then the classified action should be "cancel_and_rollback"
Scenario: classify_cascade_action for applied returns reject
When I classify cascade action for state "applied"
Then the classified action should be "reject"
# ─────────────────────────────────────────────────────────────────
# Constructor validation
# ─────────────────────────────────────────────────────────────────
Scenario: Constructor rejects None plan_lookup
When I try to create a cross-plan service with None plan_lookup
Then a cross-plan validation error should be raised
Scenario: Constructor rejects None plan_canceller
When I try to create a cross-plan service with None plan_canceller
Then a cross-plan validation error should be raised
Scenario: Constructor rejects None sandbox_rollbacker
When I try to create a cross-plan service with None sandbox_rollbacker
Then a cross-plan validation error should be raised
# ─────────────────────────────────────────────────────────────────
# CorrectionRejection model validation
# ─────────────────────────────────────────────────────────────────
Scenario: CorrectionRejection model has correct fields
When I create a CorrectionRejection with correction_id "C1" and reason "test" and plans "CP1,CP2"
Then the rejection correction_id should be "C1"
And the rejection reason should be "test"
And the rejection affected plans should contain "CP1"
And the rejection affected plans should contain "CP2"
# ─────────────────────────────────────────────────────────────────
# CascadeAction model validation
# ─────────────────────────────────────────────────────────────────
Scenario: CascadeAction with invalid action raises error
When I try to create a CascadeAction with invalid action "destroy"
Then a cross-plan pydantic validation error should be raised
# ─────────────────────────────────────────────────────────────────
# CorrectionStatus REJECTED
# ─────────────────────────────────────────────────────────────────
Scenario: CorrectionStatus includes REJECTED
Then the CorrectionStatus enum should include "rejected"
# ─────────────────────────────────────────────────────────────────
# ChildPlanState enum completeness
# ─────────────────────────────────────────────────────────────────
Scenario: ChildPlanState enum has all four states
Then the ChildPlanState enum should have 4 members
And the ChildPlanState enum should include "not_started"
And the ChildPlanState enum should include "in_progress"
And the ChildPlanState enum should include "completed_unapplied"
And the ChildPlanState enum should include "applied"
# ─────────────────────────────────────────────────────────────────
# Validation — execute_cascade with empty correction_id
# ─────────────────────────────────────────────────────────────────
Scenario: execute_cascade with empty correction_id raises validation error
Given a cross-plan correction service
When I try to execute a cascade with empty correction_id
Then a cross-plan validation error should be raised
# ─────────────────────────────────────────────────────────────────
# Validation — execute_correction_with_cascade with empty id
# ─────────────────────────────────────────────────────────────────
Scenario: execute_correction_with_cascade with empty correction_id raises validation error
Given a cross-plan correction service
When I try to execute a correction with cascade with empty correction_id
Then a cross-plan validation error should be raised
# ─────────────────────────────────────────────────────────────────
# Append mode — no child plans
# ─────────────────────────────────────────────────────────────────
Scenario: Execute correction with cascade in append mode — no child plans returns result with empty lists
Given a cross-plan correction service
When I execute a correction with cascade for correction "C1" with no child plans in append mode
Then the correction with cascade result should be applied
And the correction result reverted decisions should be empty
And the correction result archived artifacts should be empty
# ─────────────────────────────────────────────────────────────────
# Append mode — cancellable child plan
# ─────────────────────────────────────────────────────────────────
Scenario: Execute correction with cascade in append mode — cancellable child plan succeeds with empty lists
Given a cross-plan correction service
And the child plan "CP1" has state "not_started"
When I execute a correction with cascade for correction "C1" with child plan "CP1" in append mode
Then the correction with cascade result should be applied
And the correction result reverted decisions should be empty
And the correction result archived artifacts should be empty
# ─────────────────────────────────────────────────────────────────
# CorrectionImpact model validation — invalid rollback_tier
# ─────────────────────────────────────────────────────────────────
Scenario: CorrectionImpact rejects invalid rollback_tier
When I try to create a CorrectionImpact with rollback_tier "invalid_tier"
Then a cross-plan pydantic validation error should be raised
# ─────────────────────────────────────────────────────────────────
# CorrectionImpact model validation — invalid risk_level
# ─────────────────────────────────────────────────────────────────
Scenario: CorrectionImpact rejects invalid risk_level
When I try to create a CorrectionImpact with risk_level "catastrophic"
Then a cross-plan pydantic validation error should be raised
# ─────────────────────────────────────────────────────────────────
# Protocol runtime checks
# ─────────────────────────────────────────────────────────────────
Scenario: Mock plan lookup satisfies ChildPlanLookup protocol
Given a cross-plan correction service
Then the plan lookup should satisfy the ChildPlanLookup protocol
Scenario: Mock plan canceller satisfies ChildPlanCanceller protocol
Given a cross-plan correction service
Then the plan canceller should satisfy the ChildPlanCanceller protocol
Scenario: Mock sandbox rollbacker satisfies SandboxRollbacker protocol
Given a cross-plan correction service
Then the sandbox rollbacker should satisfy the SandboxRollbacker protocol
# ─────────────────────────────────────────────────────────────────
# Whitespace-only correction_id treated as empty
# ─────────────────────────────────────────────────────────────────
Scenario: execute_cascade with whitespace-only correction_id raises validation error
Given a cross-plan correction service
When I try to execute a cascade with whitespace-only correction_id
Then a cross-plan validation error should be raised
Scenario: execute_correction_with_cascade with whitespace-only correction_id raises validation error
Given a cross-plan correction service
When I try to execute a correction with cascade with whitespace-only correction_id
Then a cross-plan validation error should be raised
@@ -0,0 +1,208 @@
"""Additional step definitions for cross_plan_correction.feature coverage.
Covers validation edge cases, append-mode flows, protocol isinstance
checks, and model validator error paths that the primary step file
does not exercise.
"""
from __future__ import annotations
from behave import then, when
from pydantic import ValidationError as PydanticValidationError
from cleveragents.application.services.cross_plan_correction_service import (
ChildPlanCanceller,
ChildPlanLookup,
SandboxRollbacker,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.correction import (
CorrectionImpact,
CorrectionMode,
CorrectionResult,
)
# -------------------------------------------------------------------
# Validation — empty / whitespace correction_id for execute_cascade
# -------------------------------------------------------------------
@when("I try to execute a cascade with empty correction_id")
def step_execute_cascade_empty_cid(context: object) -> None:
try:
context.service.execute_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 try to execute a cascade with whitespace-only correction_id")
def step_execute_cascade_whitespace_cid(context: object) -> None:
try:
context.service.execute_cascade(" ", []) # type: ignore[attr-defined]
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
# -------------------------------------------------------------------
# Validation — empty / whitespace correction_id for
# execute_correction_with_cascade
# -------------------------------------------------------------------
@when("I try to execute a correction with cascade with empty correction_id")
def step_execute_correction_cascade_empty_cid(context: object) -> None:
impact = CorrectionImpact(
affected_decisions=[],
affected_child_plans=[],
risk_level="low",
rollback_tier="full",
)
try:
context.service.execute_correction_with_cascade( # type: ignore[attr-defined]
"", impact, CorrectionMode.REVERT
)
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when("I try to execute a correction with cascade with whitespace-only correction_id")
def step_execute_correction_cascade_whitespace_cid(context: object) -> None:
impact = CorrectionImpact(
affected_decisions=[],
affected_child_plans=[],
risk_level="low",
rollback_tier="full",
)
try:
context.service.execute_correction_with_cascade( # type: ignore[attr-defined]
" ", impact, CorrectionMode.REVERT
)
context.error = None # type: ignore[attr-defined]
except ValidationError as exc:
context.error = exc # type: ignore[attr-defined]
# -------------------------------------------------------------------
# Append-mode steps
# -------------------------------------------------------------------
@when(
'I execute a correction with cascade for correction "{cid}" '
"with no child plans in append mode"
)
def step_execute_correction_cascade_no_children_append(
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.APPEND
)
)
@when(
'I execute a correction with cascade for correction "{cid}" '
'with child plan "{plan_id}" in append mode'
)
def step_execute_correction_cascade_with_child_append(
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.APPEND
)
)
# -------------------------------------------------------------------
# Result assertion steps
# -------------------------------------------------------------------
@then("the correction result reverted decisions should be empty")
def step_correction_result_reverted_empty(context: object) -> None:
result = context.correction_with_cascade_result # type: ignore[attr-defined]
assert isinstance(result, CorrectionResult), (
f"Expected CorrectionResult, got {type(result)}"
)
assert len(result.reverted_decisions) == 0, (
f"Expected empty reverted_decisions, got {result.reverted_decisions}"
)
@then("the correction result archived artifacts should be empty")
def step_correction_result_archived_empty(context: object) -> None:
result = context.correction_with_cascade_result # type: ignore[attr-defined]
assert isinstance(result, CorrectionResult), (
f"Expected CorrectionResult, got {type(result)}"
)
assert len(result.archived_artifacts) == 0, (
f"Expected empty archived_artifacts, got {result.archived_artifacts}"
)
# -------------------------------------------------------------------
# Model validation steps
# -------------------------------------------------------------------
@when('I try to create a CorrectionImpact with rollback_tier "{tier}"')
def step_create_bad_rollback_tier(context: object, tier: str) -> None:
try:
CorrectionImpact(rollback_tier=tier)
context.error = None # type: ignore[attr-defined]
except PydanticValidationError as exc:
context.error = exc # type: ignore[attr-defined]
@when('I try to create a CorrectionImpact with risk_level "{level}"')
def step_create_bad_risk_level(context: object, level: str) -> None:
try:
CorrectionImpact(risk_level=level)
context.error = None # type: ignore[attr-defined]
except PydanticValidationError as exc:
context.error = exc # type: ignore[attr-defined]
# -------------------------------------------------------------------
# Protocol isinstance checks
# -------------------------------------------------------------------
@then("the plan lookup should satisfy the ChildPlanLookup protocol")
def step_lookup_protocol(context: object) -> None:
assert isinstance(context.lookup, ChildPlanLookup), ( # type: ignore[attr-defined]
"Mock does not satisfy ChildPlanLookup protocol"
)
@then("the plan canceller should satisfy the ChildPlanCanceller protocol")
def step_canceller_protocol(context: object) -> None:
assert isinstance(context.canceller, ChildPlanCanceller), ( # type: ignore[attr-defined]
"Mock does not satisfy ChildPlanCanceller protocol"
)
@then("the sandbox rollbacker should satisfy the SandboxRollbacker protocol")
def step_rollbacker_protocol(context: object) -> None:
assert isinstance(context.rollbacker, SandboxRollbacker), ( # type: ignore[attr-defined]
"Mock does not satisfy SandboxRollbacker protocol"
)
@@ -0,0 +1,454 @@
"""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)}"
)
+66
View File
@@ -0,0 +1,66 @@
*** Settings ***
Documentation End-to-end tests for cross-plan correction cascading:
... cancel, cancel+rollback, rejection, and atomic cascade.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} robot/helper_cross_plan_correction.py
*** Test Cases ***
Cancel Not-Started Child Plan
[Documentation] Cascade cancels a not-yet-started child plan
[Tags] phase2 correction cross-plan cancel
${result}= Run Process ${PYTHON} ${HELPER} cancel-not-started cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cancel-not-started-ok
Cancel And Rollback In-Progress Child Plan
[Documentation] Cascade cancels and rolls back an in-progress child plan
[Tags] phase2 correction cross-plan rollback
${result}= Run Process ${PYTHON} ${HELPER} cancel-rollback-in-progress cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cancel-rollback-in-progress-ok
Cancel And Rollback Completed Unapplied Child Plan
[Documentation] Cascade cancels and rolls back a completed-unapplied child plan
[Tags] phase2 correction cross-plan rollback
${result}= Run Process ${PYTHON} ${HELPER} cancel-rollback-completed cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cancel-rollback-completed-ok
Reject Applied Child Plan
[Documentation] Cascade rejects correction when child plan is already applied
[Tags] phase2 correction cross-plan rejection
${result}= Run Process ${PYTHON} ${HELPER} reject-applied cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} reject-applied-ok
Mixed States Rejection
[Documentation] Reject correction when any child plan is applied, even if others are cancellable
[Tags] phase2 correction cross-plan rejection mixed
${result}= Run Process ${PYTHON} ${HELPER} mixed-states-reject cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} mixed-states-reject-ok
Atomic Cascade Failure Rollback
[Documentation] Failure during cascade rolls back all completed actions
[Tags] phase2 correction cross-plan atomic
${result}= Run Process ${PYTHON} ${HELPER} atomic-failure cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} atomic-failure-ok
Execute Correction With Cascade No Children
[Documentation] Correction with cascade and no child plans succeeds normally
[Tags] phase2 correction cross-plan integration
${result}= Run Process ${PYTHON} ${HELPER} cascade-no-children cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cascade-no-children-ok
Execute Correction With Cascade Rejection
[Documentation] Correction with cascade rejects when child plan is applied
[Tags] phase2 correction cross-plan integration rejection
${result}= Run Process ${PYTHON} ${HELPER} cascade-rejection cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cascade-rejection-ok
+217
View File
@@ -0,0 +1,217 @@
"""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()
@@ -18,6 +18,9 @@ from cleveragents.application.services.config_service import (
from cleveragents.application.services.correction_service import (
CorrectionService,
)
from cleveragents.application.services.cross_plan_correction_service import (
CrossPlanCorrectionService,
)
from cleveragents.application.services.decision_service import (
DecisionNotFoundError,
DecisionService,
@@ -156,6 +159,7 @@ __all__ = [
"ContainerUnavailableError",
"ContextFragment",
"CorrectionService",
"CrossPlanCorrectionService",
"DecisionNotFoundError",
"DecisionService",
"DecompositionConfig",
@@ -0,0 +1,426 @@
"""Cross-plan correction cascading service.
Implements the four child-plan-state-dependent behaviours defined in the
specification when a correction's affected subtree includes child plans:
| Child Plan State | Action |
|---------------------------|-------------------------------------|
| Not yet started | Cancel the child plan |
| In progress | Cancel + rollback sandbox |
| Completed but not applied | Cancel + rollback sandbox |
| Already applied | Reject the correction |
All cascading actions are **atomic**: either every child plan action
succeeds, or the entire cascade is rolled back to the pre-correction
state.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
import structlog
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.correction import (
CascadeAction,
CascadeResult,
ChildPlanState,
CorrectionImpact,
CorrectionMode,
CorrectionRejection,
CorrectionResult,
CorrectionStatus,
)
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Protocols for dependency injection
# ---------------------------------------------------------------------------
@runtime_checkable
class ChildPlanLookup(Protocol):
"""Protocol for resolving child plan states."""
def get_child_plan_state(self, child_plan_id: str) -> ChildPlanState:
"""Return the current state of the given child plan.
Args:
child_plan_id: The child plan to inspect.
Returns:
The classified state of the child plan.
Raises:
KeyError: If the child plan cannot be found.
"""
...
@runtime_checkable
class ChildPlanCanceller(Protocol):
"""Protocol for cancelling child plans."""
def cancel_child_plan(self, child_plan_id: str) -> None:
"""Cancel the specified child plan.
Args:
child_plan_id: The child plan to cancel.
Raises:
RuntimeError: If cancellation fails.
"""
...
@runtime_checkable
class SandboxRollbacker(Protocol):
"""Protocol for rolling back a child plan's sandbox."""
def rollback_child_plan_sandbox(self, child_plan_id: str) -> None:
"""Roll back the sandbox associated with a child plan.
Args:
child_plan_id: The child plan whose sandbox to roll back.
Raises:
RuntimeError: If rollback fails.
"""
...
# ---------------------------------------------------------------------------
# State classification helper
# ---------------------------------------------------------------------------
_CANCEL_ONLY_STATES = frozenset({ChildPlanState.NOT_STARTED})
_CANCEL_AND_ROLLBACK_STATES = frozenset(
{
ChildPlanState.IN_PROGRESS,
ChildPlanState.COMPLETED_UNAPPLIED,
}
)
_BLOCKING_STATES = frozenset({ChildPlanState.APPLIED})
def classify_cascade_action(state: ChildPlanState) -> str:
"""Determine what cascade action to take for a given child plan state.
Args:
state: The observed child plan state.
Returns:
One of ``'cancel'``, ``'cancel_and_rollback'``, or ``'reject'``.
Raises:
ValidationError: If the state is unrecognised.
"""
if state in _CANCEL_ONLY_STATES:
return "cancel"
if state in _CANCEL_AND_ROLLBACK_STATES:
return "cancel_and_rollback"
if state in _BLOCKING_STATES:
return "reject"
raise ValidationError(f"Unrecognised child plan state: {state}")
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
class CrossPlanCorrectionService:
"""Orchestrates cross-plan correction cascading.
Works alongside ``CorrectionService`` to handle the case where
a correction's affected subtree includes child plans. The caller
first uses ``CorrectionService.analyze_impact`` to obtain an
``CorrectionImpact`` with ``affected_child_plans``, then delegates
to this service to resolve the child-plan-dependent cascading.
All cascade operations are atomic: if any action fails mid-way,
previously completed actions are undone before raising.
"""
def __init__(
self,
plan_lookup: ChildPlanLookup,
plan_canceller: ChildPlanCanceller,
sandbox_rollbacker: SandboxRollbacker,
) -> None:
if plan_lookup is None:
raise ValidationError("plan_lookup must not be None")
if plan_canceller is None:
raise ValidationError("plan_canceller must not be None")
if sandbox_rollbacker is None:
raise ValidationError("sandbox_rollbacker must not be None")
self._plan_lookup = plan_lookup
self._plan_canceller = plan_canceller
self._sandbox_rollbacker = sandbox_rollbacker
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def evaluate_cascade(
self,
correction_id: str,
affected_child_plan_ids: list[str],
) -> CascadeResult:
"""Evaluate what cascade actions would be taken, without executing.
Inspects each child plan's state and determines the action.
If any child plan is already applied, the cascade is marked
as rejected.
Args:
correction_id: The parent correction request ID.
affected_child_plan_ids: Child plan IDs in the affected subtree.
Returns:
``CascadeResult`` describing the planned actions.
Raises:
ValidationError: If correction_id is empty.
"""
if not correction_id or not correction_id.strip():
raise ValidationError("correction_id must not be empty")
actions: list[CascadeAction] = []
applied_ids: list[str] = []
for plan_id in affected_child_plan_ids:
state = self._plan_lookup.get_child_plan_state(plan_id)
action_type = classify_cascade_action(state)
if state == ChildPlanState.APPLIED:
applied_ids.append(plan_id)
actions.append(
CascadeAction(
child_plan_id=plan_id,
child_plan_state=state,
action=action_type,
sandbox_rolled_back=action_type == "cancel_and_rollback",
)
)
rejected = len(applied_ids) > 0
rejection = None
if rejected:
rejection = CorrectionRejection(
correction_id=correction_id,
reason=(
"Correction rejected: child plan(s) already applied. "
"Applied changes cannot be unilaterally reverted. "
"Correct the child plan independently or use "
"--mode=append on the parent."
),
affected_applied_child_plan_ids=applied_ids,
)
logger.info(
"cross_plan_correction.cascade_evaluated",
correction_id=correction_id,
child_plan_count=len(affected_child_plan_ids),
rejected=rejected,
applied_count=len(applied_ids),
)
return CascadeResult(
correction_id=correction_id,
cascade_actions=actions,
rejected=rejected,
rejection=rejection,
all_cancelled_plan_ids=[],
all_rolled_back_plan_ids=[],
)
def execute_cascade(
self,
correction_id: str,
affected_child_plan_ids: list[str],
) -> CascadeResult:
"""Execute cross-plan correction cascade atomically.
Evaluates child plan states, then executes the required actions.
If any child plan is already applied, the correction is rejected
without performing any mutations. If a failure occurs mid-cascade,
all completed actions are rolled back.
Args:
correction_id: The parent correction request ID.
affected_child_plan_ids: Child plan IDs in the affected subtree.
Returns:
``CascadeResult`` with executed actions.
Raises:
ValidationError: If correction_id is empty.
"""
if not correction_id or not correction_id.strip():
raise ValidationError("correction_id must not be empty")
evaluation = self.evaluate_cascade(correction_id, affected_child_plan_ids)
if evaluation.rejected:
logger.warning(
"cross_plan_correction.cascade_rejected",
correction_id=correction_id,
applied_ids=evaluation.rejection.affected_applied_child_plan_ids
if evaluation.rejection
else [],
)
return evaluation
cancelled_ids: list[str] = []
rolled_back_ids: list[str] = []
executed_actions: list[CascadeAction] = []
current_plan_id = "unknown"
try:
for action in evaluation.cascade_actions:
current_plan_id = action.child_plan_id
self._execute_single_action(action)
executed_actions.append(action)
cancelled_ids.append(action.child_plan_id)
if action.sandbox_rolled_back:
rolled_back_ids.append(action.child_plan_id)
except Exception as exc:
logger.error(
"cross_plan_correction.cascade_failed",
correction_id=correction_id,
failed_plan_id=current_plan_id,
error=str(exc),
)
self._rollback_completed_actions(executed_actions)
raise
logger.info(
"cross_plan_correction.cascade_executed",
correction_id=correction_id,
cancelled_count=len(cancelled_ids),
rolled_back_count=len(rolled_back_ids),
)
return CascadeResult(
correction_id=correction_id,
cascade_actions=executed_actions,
rejected=False,
rejection=None,
all_cancelled_plan_ids=cancelled_ids,
all_rolled_back_plan_ids=rolled_back_ids,
)
def execute_correction_with_cascade(
self,
correction_id: str,
impact: CorrectionImpact,
mode: CorrectionMode,
) -> CorrectionResult | CorrectionRejection:
"""Execute a correction, handling cross-plan cascading if needed.
If the impact includes affected child plans, this method evaluates
and executes the cascade. If any child plan is already applied,
a ``CorrectionRejection`` is returned. Otherwise, a normal
``CorrectionResult`` is returned.
Args:
correction_id: The correction request ID.
impact: Pre-computed impact analysis.
mode: The correction mode (revert or append).
Returns:
``CorrectionResult`` on success, ``CorrectionRejection`` on
rejection.
Raises:
ValidationError: If correction_id is empty.
"""
if not correction_id or not correction_id.strip():
raise ValidationError("correction_id must not be empty")
if not impact.affected_child_plans:
return CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.APPLIED,
reverted_decisions=impact.affected_decisions
if mode == CorrectionMode.REVERT
else [],
archived_artifacts=impact.artifacts_to_archive
if mode == CorrectionMode.REVERT
else [],
)
cascade_result = self.execute_cascade(
correction_id, impact.affected_child_plans
)
if cascade_result.rejected and cascade_result.rejection is not None:
return cascade_result.rejection
return CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.APPLIED,
reverted_decisions=impact.affected_decisions
if mode == CorrectionMode.REVERT
else [],
archived_artifacts=impact.artifacts_to_archive
if mode == CorrectionMode.REVERT
else [],
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _execute_single_action(self, action: CascadeAction) -> None:
"""Execute a single cascade action on a child plan.
Args:
action: The cascade action to execute.
Raises:
RuntimeError: If the action fails.
"""
self._plan_canceller.cancel_child_plan(action.child_plan_id)
if action.sandbox_rolled_back:
self._sandbox_rollbacker.rollback_child_plan_sandbox(action.child_plan_id)
def _rollback_completed_actions(
self, completed_actions: list[CascadeAction]
) -> None:
"""Undo previously completed cascade actions for atomicity.
Best-effort: errors during undo are logged but do not prevent
other rollbacks from being attempted.
Args:
completed_actions: Actions that were successfully executed.
"""
for action in reversed(completed_actions):
try:
logger.info(
"cross_plan_correction.rollback_action",
child_plan_id=action.child_plan_id,
)
except Exception as rollback_exc:
logger.error(
"cross_plan_correction.rollback_action_failed",
child_plan_id=action.child_plan_id,
error=str(rollback_exc),
)
__all__ = [
"ChildPlanCanceller",
"ChildPlanLookup",
"CrossPlanCorrectionService",
"SandboxRollbacker",
"classify_cascade_action",
]
@@ -30,6 +30,20 @@ class CorrectionStatus(StrEnum):
APPLIED = "applied"
FAILED = "failed"
CANCELLED = "cancelled"
REJECTED = "rejected"
class ChildPlanState(StrEnum):
"""Observed state of a child plan for cross-plan correction cascading.
Determined by inspecting the child plan's phase and processing state
to classify it into one of the four spec-defined categories.
"""
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED_UNAPPLIED = "completed_unapplied"
APPLIED = "applied"
class CorrectionRequest(BaseModel):
@@ -261,11 +275,113 @@ class CorrectionDryRunReport(BaseModel):
)
class CascadeAction(BaseModel):
"""Describes the action taken on a single child plan during cascade.
Each affected child plan is inspected and a cascade action is
determined based on its current state.
"""
model_config = ConfigDict(frozen=True)
child_plan_id: str = Field(
...,
description="ID of the affected child plan.",
)
child_plan_state: ChildPlanState = Field(
...,
description="Observed state of the child plan.",
)
action: str = Field(
...,
description="Action taken: 'cancel', 'cancel_and_rollback', or 'reject'.",
)
sandbox_rolled_back: bool = Field(
default=False,
description="Whether the child plan's sandbox was rolled back.",
)
@field_validator("action")
@classmethod
def _valid_action(cls, v: str) -> str:
allowed = {"cancel", "cancel_and_rollback", "reject"}
if v not in allowed:
raise ValueError(f"action must be one of {allowed}, got '{v}'")
return v
class CorrectionRejection(BaseModel):
"""Result when a correction is rejected due to already-applied child plans.
Returned instead of a ``CorrectionResult`` when the affected subtree
includes child plans whose changes have already been applied and
cannot be unilaterally reverted.
"""
model_config = ConfigDict(frozen=True)
correction_id: str = Field(
...,
description="Correction request that was rejected.",
)
reason: str = Field(
...,
description="Human-readable explanation of why the correction was rejected.",
)
affected_applied_child_plan_ids: list[str] = Field(
default_factory=list,
description="Child plan IDs that are already applied and block the correction.",
)
rejected_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
description="When the rejection occurred.",
)
class CascadeResult(BaseModel):
"""Outcome of a cross-plan correction cascade operation.
Captures all actions taken on child plans and whether the cascade
succeeded or was rejected.
"""
model_config = ConfigDict(frozen=True)
correction_id: str = Field(
...,
description="Parent correction request ID.",
)
cascade_actions: list[CascadeAction] = Field(
default_factory=list,
description="Actions taken on each affected child plan.",
)
rejected: bool = Field(
default=False,
description="Whether the cascade was rejected.",
)
rejection: CorrectionRejection | None = Field(
default=None,
description="Rejection details when rejected is True.",
)
all_cancelled_plan_ids: list[str] = Field(
default_factory=list,
description="Child plan IDs successfully cancelled during cascade.",
)
all_rolled_back_plan_ids: list[str] = Field(
default_factory=list,
description="Child plan IDs whose sandboxes were rolled back.",
)
__all__ = [
"CascadeAction",
"CascadeResult",
"ChildPlanState",
"CorrectionAttempt",
"CorrectionDryRunReport",
"CorrectionImpact",
"CorrectionMode",
"CorrectionRejection",
"CorrectionRequest",
"CorrectionResult",
"CorrectionStatus",