Files
cleveragents-core/features/steps/plan_persistence_steps.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

579 lines
20 KiB
Python

"""Step definitions for plan persistence feature.
Tests LifecyclePlanRepository CRUD, phase/state transitions,
list filters, plan tree links, and cross-restart durability.
"""
from __future__ import annotations
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
from cleveragents.infrastructure.database.models import (
Base,
)
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
LifecyclePlanRepository,
)
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_counter = 0
def _next_ulid() -> str:
"""Generate a monotonically-increasing ULID for test isolation."""
global _counter
_counter += 1
n = _counter
suffix = ""
for _ in range(8):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HGZ6FE0AQDYTR4BX{suffix}"
def _make_plan(
plan_id: str,
action_name: str = "local/persist-action",
phase: PlanPhase = PlanPhase.ACTION,
processing_state: ProcessingState = ProcessingState.QUEUED,
parent_plan_id: str | None = None,
root_plan_id: str | None = None,
project_links: list[ProjectLink] | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
invariants: list[PlanInvariant] | None = None,
) -> Plan:
"""Build a Plan domain object for testing."""
now = datetime(2026, 3, 1, tzinfo=UTC)
return Plan(
identity=PlanIdentity(
plan_id=plan_id,
parent_plan_id=parent_plan_id,
root_plan_id=root_plan_id,
attempt=1,
),
namespaced_name=NamespacedName(namespace="local", name="persist-plan"),
action_name=action_name,
description="Persistence test plan",
definition_of_done="All assertions pass",
phase=phase,
processing_state=processing_state,
strategy_actor="local/s",
execution_actor="local/e",
project_links=project_links or [],
invariants=invariants or [],
arguments=arguments or {},
arguments_order=arguments_order or [],
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by="test-persist",
tags=[],
reusable=True,
read_only=False,
)
def _setup_db(context: Context, *, file_based: bool = False) -> None:
"""Create a SQLite DB and attach repos to context.
By default uses in-memory SQLite (fast). Pass ``file_based=True`` for
cross-restart scenarios that need to close and reopen the same database
file.
"""
if file_based:
tmp = tempfile.mktemp(suffix=".db")
db_url = f"sqlite:///{tmp}"
context._pp_db_path = tmp
else:
db_url = "sqlite://"
context._pp_db_path = None
engine = create_engine(db_url, echo=False)
Base.metadata.create_all(engine)
sm = sessionmaker(bind=engine)
session = sm()
context._pp_db_url = db_url
context._pp_engine = engine
context._pp_session = session
context._pp_session_factory = lambda: session
context._pp_plan_repo = LifecyclePlanRepository(
session_factory=context._pp_session_factory,
)
context._pp_action_repo = ActionRepository(
session_factory=context._pp_session_factory,
)
def _teardown_db(context: Context) -> None:
"""Close the session and clean up temp DB file."""
if hasattr(context, "_pp_session"):
context._pp_session.close()
if hasattr(context, "_pp_engine"):
context._pp_engine.dispose()
if getattr(context, "_pp_db_path", None):
Path(context._pp_db_path).unlink(missing_ok=True)
def _create_action(context: Context, action_name: str = "local/persist-action") -> None:
"""Ensure an action exists for FK constraint."""
ns = NamespacedName.parse(action_name)
action = Action(
namespaced_name=ns,
description="Prerequisite action for plan persistence tests",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
state=ActionState.AVAILABLE,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
context._pp_action_repo.create(action)
context._pp_session.commit()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a fresh in-memory plan persistence database")
def step_fresh_plan_persistence_db(context: Context) -> None:
"""Set up a clean in-memory SQLite database for plan persistence tests."""
_setup_db(context)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _teardown_db(context))
@given("the plan persistence database is file-based")
def step_file_based_plan_persistence_db(context: Context) -> None:
"""Re-create the plan persistence DB on disk for cross-restart tests.
The Background already creates an in-memory DB. This step replaces it
with a file-backed DB so the "close and reopen" step can reopen the same
file after engine disposal.
"""
_teardown_db(context)
_setup_db(context, file_based=True)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _teardown_db(context))
@given('a prerequisite action "{action_name}" exists in the database')
def step_prerequisite_action_exists(context: Context, action_name: str) -> None:
"""Create a prerequisite action for FK constraints."""
_create_action(context, action_name)
# ---------------------------------------------------------------------------
# Create scenarios
# ---------------------------------------------------------------------------
@given('a new lifecycle plan with ID "{plan_id}"')
def step_new_lifecycle_plan(context: Context, plan_id: str) -> None:
"""Build a new Plan domain object."""
context._pp_plan = _make_plan(plan_id)
@given(
'a new lifecycle plan with parent tree ID "{plan_id}" parent "{parent_id}" root "{root_id}"'
)
def step_new_lifecycle_plan_with_tree(
context: Context, plan_id: str, parent_id: str, root_id: str
) -> None:
"""Build a new Plan with parent/root IDs."""
# Ensure parent plan exists first
parent = _make_plan(parent_id)
context._pp_plan_repo.create(parent)
context._pp_session.commit()
context._pp_plan = _make_plan(
plan_id, parent_plan_id=parent_id, root_plan_id=root_id
)
@when("I persist the plan via the plan repository")
def step_persist_plan(context: Context) -> None:
"""Persist the plan through the repository."""
context._pp_plan_repo.create(context._pp_plan)
context._pp_session.commit()
@then('I can retrieve the plan by ID "{plan_id}"')
def step_retrieve_plan_by_id(context: Context, plan_id: str) -> None:
"""Retrieve the plan and store for further assertions."""
plan = context._pp_plan_repo.get(plan_id)
assert plan is not None, f"Plan {plan_id} not found"
context._pp_retrieved = plan
@then('the persisted plan description should be "{description}"')
def step_plan_description(context: Context, description: str) -> None:
"""Verify the plan's description."""
assert context._pp_retrieved.description == description
@then('the plan parent_plan_id should be "{parent_id}"')
def step_plan_parent_id(context: Context, parent_id: str) -> None:
"""Verify the plan's parent_plan_id."""
assert context._pp_retrieved.identity.parent_plan_id == parent_id
@then('the plan root_plan_id should be "{root_id}"')
def step_plan_root_id(context: Context, root_id: str) -> None:
"""Verify the plan's root_plan_id."""
assert context._pp_retrieved.identity.root_plan_id == root_id
# ---------------------------------------------------------------------------
# Phase/state transition scenarios
# ---------------------------------------------------------------------------
@given('a persisted plan in phase "{phase}" with state "{state}"')
def step_persisted_plan_phase_state(context: Context, phase: str, state: str) -> None:
"""Create and persist a plan in the given phase/state."""
plan_id = _next_ulid()
plan = _make_plan(
plan_id,
phase=PlanPhase(phase),
processing_state=ProcessingState(state),
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
context._pp_current_plan_id = plan_id
@when('I update the plan phase to "{phase}" with state "{state}"')
def step_update_plan_phase_state(context: Context, phase: str, state: str) -> None:
"""Update the plan's phase and processing state.
Sets processing_state before phase so that the per-phase state
validator sees a universally valid state (e.g. QUEUED) when the
phase assignment triggers re-validation.
"""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
plan.processing_state = ProcessingState(state)
plan.phase = PlanPhase(phase)
plan.timestamps.updated_at = datetime.now(tz=UTC)
context._pp_plan_repo.update(plan)
context._pp_session.commit()
@then('the plan persistence phase should be "{phase}"')
def step_verify_plan_phase(context: Context, phase: str) -> None:
"""Verify the persisted plan's phase."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
assert plan.phase == PlanPhase(phase), f"Expected {phase}, got {plan.phase}"
@then('the persisted plan processing_state should be "{state}"')
def step_verify_plan_state(context: Context, state: str) -> None:
"""Verify the persisted plan's processing state."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
assert plan.processing_state == ProcessingState(state), (
f"Expected {state}, got {plan.processing_state}"
)
# ---------------------------------------------------------------------------
# List filter scenarios
# ---------------------------------------------------------------------------
@given('{count:d} persisted plans in phase "{phase}"')
def step_n_plans_in_phase(context: Context, count: int, phase: str) -> None:
"""Create N plans in the given phase."""
for _ in range(count):
plan_id = _next_ulid()
plan = _make_plan(plan_id, phase=PlanPhase(phase))
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@given('{count:d} persisted plans with processing state "{state}"')
def step_n_plans_in_state(context: Context, count: int, state: str) -> None:
"""Create N plans with the given processing state."""
for _ in range(count):
plan_id = _next_ulid()
plan = _make_plan(plan_id, processing_state=ProcessingState(state))
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@given('{count:d} persisted plan with processing state "{state}"')
def step_one_plan_in_state(context: Context, count: int, state: str) -> None:
"""Create 1 plan with the given processing state (singular)."""
for _ in range(count):
plan_id = _next_ulid()
plan = _make_plan(plan_id, processing_state=ProcessingState(state))
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@given('{count:d} persisted plans linked to action "{action_name}"')
def step_n_plans_linked_to_action(
context: Context, count: int, action_name: str
) -> None:
"""Create N plans linked to the given action."""
for _ in range(count):
plan_id = _next_ulid()
plan = _make_plan(plan_id, action_name=action_name)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@when('I list persisted plans filtered by phase "{phase}"')
def step_list_plans_by_phase(context: Context, phase: str) -> None:
"""List plans filtered by phase."""
context._pp_listed = context._pp_plan_repo.list_plans(phase=phase)
@when('I list plans filtered by processing_state "{state}"')
def step_list_plans_by_state(context: Context, state: str) -> None:
"""List plans filtered by processing state."""
context._pp_listed = context._pp_plan_repo.list_plans(processing_state=state)
@when('I list plans filtered by action_name "{action_name}"')
def step_list_plans_by_action(context: Context, action_name: str) -> None:
"""List plans filtered by action name."""
context._pp_listed = context._pp_plan_repo.list_plans(action_name=action_name)
@then("I should get {count:d} plans in the list")
def step_verify_plan_count(context: Context, count: int) -> None:
"""Verify the number of plans returned."""
assert len(context._pp_listed) == count, (
f"Expected {count}, got {len(context._pp_listed)}"
)
# ---------------------------------------------------------------------------
# Plan tree link scenarios
# ---------------------------------------------------------------------------
@given('a parent plan with ID "{plan_id}"')
def step_parent_plan(context: Context, plan_id: str) -> None:
"""Create and persist a parent plan."""
plan = _make_plan(plan_id, phase=PlanPhase.EXECUTE)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@given('a child plan with ID "{child_id}" under parent "{parent_id}"')
def step_child_plan(context: Context, child_id: str, parent_id: str) -> None:
"""Create and persist a child plan referencing the parent."""
plan = _make_plan(
child_id,
parent_plan_id=parent_id,
root_plan_id=parent_id,
phase=PlanPhase.STRATEGIZE,
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@when('I retrieve the child plan "{plan_id}"')
def step_retrieve_child(context: Context, plan_id: str) -> None:
"""Retrieve a child plan from the repo."""
context._pp_child = context._pp_plan_repo.get(plan_id)
assert context._pp_child is not None
@then('the child plan should reference parent "{parent_id}"')
def step_child_references_parent(context: Context, parent_id: str) -> None:
"""Verify the child plan's parent reference."""
assert context._pp_child.identity.parent_plan_id == parent_id
@then('the child plan should reference root "{root_id}"')
def step_child_references_root(context: Context, root_id: str) -> None:
"""Verify the child plan's root reference."""
assert context._pp_child.identity.root_plan_id == root_id
@given('a root plan with ID "{plan_id}"')
def step_root_plan(context: Context, plan_id: str) -> None:
"""Create and persist a root plan."""
plan = _make_plan(plan_id, phase=PlanPhase.EXECUTE)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@given('a mid plan with ID "{mid_id}" under root "{root_id}"')
def step_mid_plan(context: Context, mid_id: str, root_id: str) -> None:
"""Create and persist a mid-level plan."""
plan = _make_plan(
mid_id,
parent_plan_id=root_id,
root_plan_id=root_id,
phase=PlanPhase.STRATEGIZE,
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@given(
'a leaf plan with ID "{leaf_id}" under parent "{parent_id}" and root "{root_id}"'
)
def step_leaf_plan(
context: Context, leaf_id: str, parent_id: str, root_id: str
) -> None:
"""Create and persist a leaf plan."""
plan = _make_plan(
leaf_id,
parent_plan_id=parent_id,
root_plan_id=root_id,
phase=PlanPhase.STRATEGIZE,
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
@when('I retrieve the leaf plan "{plan_id}"')
def step_retrieve_leaf(context: Context, plan_id: str) -> None:
"""Retrieve a leaf plan from the repo."""
context._pp_leaf = context._pp_plan_repo.get(plan_id)
assert context._pp_leaf is not None
@then('the leaf plan parent should be "{parent_id}"')
def step_leaf_parent(context: Context, parent_id: str) -> None:
"""Verify the leaf plan's parent reference."""
assert context._pp_leaf.identity.parent_plan_id == parent_id
@then('the leaf plan root should be "{root_id}"')
def step_leaf_root(context: Context, root_id: str) -> None:
"""Verify the leaf plan's root reference."""
assert context._pp_leaf.identity.root_plan_id == root_id
# ---------------------------------------------------------------------------
# Cross-restart scenarios
# ---------------------------------------------------------------------------
@when("I close and reopen the persistence database")
def step_reopen_database(context: Context) -> None:
"""Close the session and engine, then reopen from the same file."""
db_path = context._pp_db_path
context._pp_session.close()
context._pp_engine.dispose()
engine = create_engine(f"sqlite:///{db_path}", echo=False)
sm = sessionmaker(bind=engine)
session = sm()
context._pp_engine = engine
context._pp_session = session
context._pp_session_factory = lambda: session
context._pp_plan_repo = LifecyclePlanRepository(
session_factory=context._pp_session_factory,
)
context._pp_action_repo = ActionRepository(
session_factory=context._pp_session_factory,
)
@then('the plan should still be in phase "{phase}" with state "{state}"')
def step_plan_phase_after_restart(context: Context, phase: str, state: str) -> None:
"""Verify plan phase/state after reconnection."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None, "Plan not found after reconnect"
assert plan.phase == PlanPhase(phase)
assert plan.processing_state == ProcessingState(state)
@given('a persisted plan with project links "{link1}" and "{link2}"')
def step_plan_with_project_links(context: Context, link1: str, link2: str) -> None:
"""Create a plan with project links."""
plan_id = _next_ulid()
plan = _make_plan(
plan_id,
project_links=[
ProjectLink(project_name=link1),
ProjectLink(project_name=link2),
],
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
context._pp_current_plan_id = plan_id
@then("the plan should still have {count:d} project links")
def step_plan_project_links_count(context: Context, count: int) -> None:
"""Verify the plan still has the expected number of project links."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
assert len(plan.project_links) == count
@given('a persisted plan with arguments "{arg1}" and "{arg2}"')
def step_plan_with_arguments(context: Context, arg1: str, arg2: str) -> None:
"""Create a plan with arguments."""
plan_id = _next_ulid()
plan = _make_plan(
plan_id,
arguments={arg1: "value1", arg2: "value2"},
arguments_order=[arg1, arg2],
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
context._pp_current_plan_id = plan_id
@then("the plan should still have {count:d} arguments in order")
def step_plan_arguments_count(context: Context, count: int) -> None:
"""Verify the plan still has the expected number of arguments."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
assert len(plan.arguments) == count
assert len(plan.arguments_order) == count
@given('a persisted plan with invariant "{text}"')
def step_plan_with_invariant(context: Context, text: str) -> None:
"""Create a plan with an invariant."""
plan_id = _next_ulid()
plan = _make_plan(
plan_id,
invariants=[PlanInvariant(text=text, source=InvariantSource.PLAN)],
)
context._pp_plan_repo.create(plan)
context._pp_session.commit()
context._pp_current_plan_id = plan_id
@then('the plan should still have invariant "{text}"')
def step_plan_has_invariant(context: Context, text: str) -> None:
"""Verify the plan still has the invariant."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
invariant_texts = [inv.text for inv in plan.invariants]
assert text in invariant_texts, f"Expected '{text}' in {invariant_texts}"