Files
cleveragents-core/robot/helper_phase_reversion.py
Luis Mendes 9e316b1a3e
CI / build (pull_request) Successful in 22s
CI / typecheck (pull_request) Successful in 47s
CI / lint (pull_request) Successful in 3m17s
CI / quality (pull_request) Successful in 3m33s
CI / security (pull_request) Successful in 3m59s
CI / unit_tests (pull_request) Successful in 5m31s
CI / docker (pull_request) Successful in 57s
CI / coverage (pull_request) Successful in 12m32s
CI / status-check (pull_request) Successful in 1s
CI / integration_tests (pull_request) Successful in 2m25s
CI / e2e_tests (pull_request) Successful in 7m9s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (push) Successful in 26s
CI / lint (push) Successful in 3m18s
CI / typecheck (push) Successful in 3m54s
CI / security (push) Successful in 4m2s
CI / quality (push) Successful in 3m33s
CI / integration_tests (push) Successful in 5m47s
CI / unit_tests (push) Successful in 7m6s
CI / docker (push) Successful in 39s
CI / benchmark-regression (pull_request) Failing after 21m11s
CI / coverage (push) Failing after 19m3s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Failing after 21m14s
CI / status-check (push) Failing after 1s
CI / benchmark-publish (push) Failing after 18m51s
fix(domain): align plan lifecycle model validation with specification
Aligned the plan lifecycle model with the specification:

1. ERRORED is now treated as terminal in is_terminal property,
   matching the spec table where errored is marked "Terminal? Yes"
   for all processing phases.

2. Added per-phase state validation via model_validator: APPLIED
   and CONSTRAINED are only valid in APPLY phase; COMPLETE is only
   valid in STRATEGIZE or EXECUTE phases. Invalid combinations
   now raise ValueError at construction time.

3. Updated ProcessingState.COMPLETE docstring to clarify phase-level
   terminality semantics.

4. Fixed assignment ordering in execute_plan() to set
   processing_state before phase, consistent with the state-first
   pattern used in apply_plan() and _perform_reversion().

5. Added defensive coercion in LifecyclePlanModel.to_domain() to
   handle legacy DB rows with invalid phase/state combinations
   (e.g. APPLY/COMPLETE -> APPLY/APPLIED) with warning-level
   logging for observability.

6. Updated module docstrings: ERRORED description now reflects
   terminal semantics, terminal outcomes location clarified for
   all phases, can_revert_to docstring notes ERRORED/CONSTRAINED
   are terminal but revertable, is_terminal docstring explains
   the distinction between terminal and permanently irrecoverable
   and documents why COMPLETE is not plan-terminal despite the
   spec marking it "Terminal? Yes" (phase-level vs plan-level).

7. Updated PlanResumeService.validate_eligibility() docstring to
   reflect that ERRORED is now terminal but still eligible for
   resume.

8. Added CHANGELOG entry.

ISSUES CLOSED: #918
2026-03-23 23:33:33 +00:00

284 lines
9.3 KiB
Python

"""Helper script for Robot Framework phase reversion tests."""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core.action import (
ActionArgument,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
PlanInvariant,
PlanPhase,
ProcessingState,
ProjectLink,
)
def _make_service() -> PlanLifecycleService:
"""Create a fresh PlanLifecycleService."""
return PlanLifecycleService(settings=Settings())
def _create_plan(
service: PlanLifecycleService,
action_name: str = "local/test-action",
profile: str = "manual",
) -> str:
"""Create an action and plan, return plan_id."""
service.create_action(
name=action_name,
description=f"Test {action_name}",
definition_of_done="Tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
)
plan = service.use_action(action_name)
plan.automation_profile = AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
return plan.identity.plan_id
def _advance_to_execute(service: PlanLifecycleService, plan_id: str) -> None:
"""Advance plan to Execute phase."""
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
plan = service.get_plan(plan_id)
if plan.phase != PlanPhase.EXECUTE:
service.execute_plan(plan_id)
def _advance_to_apply_constrained(
service: PlanLifecycleService,
plan_id: str,
) -> None:
"""Advance plan to Apply/constrained."""
_advance_to_execute(service, plan_id)
service.start_execute(plan_id)
service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
service.apply_plan(plan_id)
plan = service.get_plan(plan_id)
if plan.processing_state == ProcessingState.QUEUED:
service.start_apply(plan_id)
plan = service.get_plan(plan_id)
plan.processing_state = ProcessingState.CONSTRAINED
plan.error_message = "Constraints too restrictive"
def _advance_to_applied(service: PlanLifecycleService, plan_id: str) -> None:
"""Advance plan to Applied terminal state."""
_advance_to_execute(service, plan_id)
service.start_execute(plan_id)
service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
service.apply_plan(plan_id)
plan = service.get_plan(plan_id)
if plan.processing_state == ProcessingState.QUEUED:
service.start_apply(plan_id)
service.complete_apply(plan_id)
def test_manual_revert() -> None:
"""Test manual reversion from Execute to Strategize."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_execute(service, plan_id)
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="test revert")
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.processing_state == ProcessingState.QUEUED
assert plan.reversion_count == 1
assert len(plan.decisions) == 1
assert plan.decisions[0]["type"] == "reversion"
print("reversion-manual-ok")
def test_auto_revert_apply() -> None:
"""Test auto-reversion from constrained Apply."""
service = _make_service()
plan_id = _create_plan(service, profile="ci")
_advance_to_apply_constrained(service, plan_id)
plan = service.try_auto_revert_from_apply(plan_id, "constraints too strict")
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.reversion_count == 1
print("reversion-auto-apply-ok")
def test_loop_guard() -> None:
"""Test loop guard prevents more than 3 reversions."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_execute(service, plan_id)
plan = service.get_plan(plan_id)
plan.reversion_count = 3
try:
service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="should fail")
print("ERROR: should have raised PlanError")
sys.exit(1)
except PlanError as e:
assert "maximum reversion count" in str(e).lower() or "max" in str(e).lower()
print("loop-guard-ok")
def test_profile_gate() -> None:
"""Test that manual profile blocks auto-reversion."""
service = _make_service()
plan_id = _create_plan(service, profile="manual")
_advance_to_apply_constrained(service, plan_id)
plan = service.try_auto_revert_from_apply(plan_id, "should stay")
assert plan.phase == PlanPhase.APPLY
assert plan.processing_state == ProcessingState.CONSTRAINED
print("profile-gate-ok")
def test_terminal_block() -> None:
"""Test that applied terminal plan cannot be reverted."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_applied(service, plan_id)
try:
service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="should fail")
print("ERROR: should have raised PlanError")
sys.exit(1)
except PlanError as e:
assert "terminal" in str(e).lower()
print("terminal-block-ok")
def test_context_preserved() -> None:
"""Test that plan context is preserved through reversion."""
service = _make_service()
service.create_action(
name="local/ctx-test",
description="Context test",
definition_of_done="Tests pass",
strategy_actor="local/strategist",
execution_actor="local/executor",
arguments=[
ActionArgument(
name="coverage",
arg_type=ArgumentType.INTEGER,
requirement=ArgumentRequirement.OPTIONAL,
description="Coverage threshold",
),
],
)
plan = service.use_action(
"local/ctx-test",
project_links=[ProjectLink(project_name="local/my-project")],
arguments={"coverage": 95},
invariants=[PlanInvariant(text="No regressions", source=InvariantSource.PLAN)],
)
plan.automation_profile = AutomationProfileRef(
profile_name="manual",
provenance=AutomationProfileProvenance.PLAN,
)
plan_id = plan.identity.plan_id
service.start_strategize(plan_id)
service.complete_strategize(plan_id)
service.execute_plan(plan_id)
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="context check")
assert plan.arguments == {"coverage": 95}
assert len(plan.project_links) == 1
assert plan.project_links[0].project_name == "local/my-project"
inv_texts = [inv.text for inv in plan.invariants]
assert "No regressions" in inv_texts
print("context-preserved-ok")
def test_decision_recording() -> None:
"""Test that each reversion is recorded as a decision."""
service = _make_service()
plan_id = _create_plan(service)
_advance_to_execute(service, plan_id)
plan = service.revert_plan(plan_id, PlanPhase.STRATEGIZE, reason="first")
assert len(plan.decisions) == 1
assert plan.decisions[0]["type"] == "reversion"
assert plan.decisions[0]["source_phase"] == "execute"
assert plan.decisions[0]["target_phase"] == "strategize"
assert plan.decisions[0]["reversion_number"] == 1
assert "timestamp" in plan.decisions[0]
print("decision-recording-ok")
def test_can_revert_to() -> None:
"""Test can_revert_to validation."""
service = _make_service()
plan_id = _create_plan(service)
plan = service.get_plan(plan_id)
# Strategize cannot revert to Apply or Action
plan.phase = PlanPhase.STRATEGIZE
plan.processing_state = ProcessingState.QUEUED
assert not plan.can_revert_to(PlanPhase.APPLY)
assert not plan.can_revert_to(PlanPhase.ACTION)
# Execute can revert to Strategize
plan.phase = PlanPhase.EXECUTE
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
# Apply can revert to Strategize
plan.phase = PlanPhase.APPLY
plan.processing_state = ProcessingState.CONSTRAINED
assert plan.can_revert_to(PlanPhase.STRATEGIZE)
# Applied cannot revert
plan.processing_state = ProcessingState.APPLIED
assert not plan.can_revert_to(PlanPhase.STRATEGIZE)
# Max reversions reached blocks reversion — set processing_state
# first so the phase-state validator sees QUEUED (valid in any phase).
plan.processing_state = ProcessingState.QUEUED
plan.phase = PlanPhase.EXECUTE
plan.reversion_count = 3
assert not plan.can_revert_to(PlanPhase.STRATEGIZE)
print("can-revert-to-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "manual_revert"
dispatch = {
"manual_revert": test_manual_revert,
"auto_revert_apply": test_auto_revert_apply,
"loop_guard": test_loop_guard,
"profile_gate": test_profile_gate,
"terminal_block": test_terminal_block,
"context_preserved": test_context_preserved,
"decision_recording": test_decision_recording,
"can_revert_to": test_can_revert_to,
}
fn = dispatch.get(cmd)
if fn:
fn()
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)