Files
cleveragents-core/features/steps/plan_persistence_steps.py
freemo 4ad51561fc perf(tests): optimize medium-slow BDD features (10-100s tier)
Cap time.sleep and asyncio.sleep globally at 10ms in before_all to
eliminate retry/backoff waits (tenacity wait_fixed, retry_auto_debug
exponential backoff). Save originals as time._original_sleep and
asyncio._original_sleep for tests that need real wall-clock delays
(CircuitBreaker recovery, debounce timers, validation timeouts).

Enhance template-DB patch: add "db." prefix to _SCENARIO_DB_PREFIXES
and check db_path.stat().st_size > 0 so 0-byte auto-created SQLite
files receive the template copy instead of falling through to real
migrations.

Replace subprocess.run CLI invocations with typer.testing.CliRunner
in module_coverage, main_coverage_complete, and coverage_extras step
files, eliminating ~6s Python cold-start overhead per call.

Switch plan_persistence and action_persistence to in-memory SQLite by
default; only cross-restart scenarios use file-based via an explicit
Given step.

Replace MigrationRunner.run_migrations with Base.metadata.create_all
in plan_service_steps for freshly created in-memory engines.

Removed leftover debug comment in environment.py.

Total tier runtime: 565s -> 21s (96% reduction), 20 features all
under 5s behave-internal time.

ISSUES CLOSED: #480
2026-03-02 02:01:27 +00:00

574 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."""
plan = context._pp_plan_repo.get(context._pp_current_plan_id)
assert plan is not None
plan.phase = PlanPhase(phase)
plan.processing_state = ProcessingState(state)
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}"