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
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
284 lines
9.3 KiB
Python
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)
|