Files
temp/features/steps/plan_lifecycle_service_coverage_boost_steps.py
freemo c4f71e930d feat(concurrency): add plan resume
Implement step-level progress persistence and plan resume with graceful
shutdown handling.

- Add ResumeCheckpoint, ResumeMetadata, ResumeEligibility, ResumeSummary
  domain models (resume.py)
- Add PlanResumeService with validate_eligibility(), build_resume_summary(),
  resume_plan(dry_run), record_step_checkpoint(), record_shutdown()
- Add last_completed_step and last_checkpoint_id fields to Plan model
- Add 'plan resume' CLI command with --dry-run flag
- Update plan lifecycle docs (ADR-006) with resume behavior section
- Add Behave tests (24 scenarios in plan_resume.feature)
- Add Robot Framework integration tests (10 tests)
- Add ASV benchmarks for resume overhead

Closes #328
2026-02-25 14:00:04 -05:00

326 lines
12 KiB
Python

"""Step definitions for plan_lifecycle_service_coverage_boost.feature.
Targets uncovered lines and branches in PlanLifecycleService:
- Line 284: get_action / get_action_by_name case-insensitive lookup
- Line 420: use_action PlanInvariant append from action.invariants
- Line 643: execute_plan InvalidPhaseTransitionError
- Lines 829-846: constrain_apply method (happy + wrong-phase)
- Line 959: auto_progress final return (no condition matched)
"""
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError
from cleveragents.domain.models.core.plan import (
InvariantSource,
PlanPhase,
ProcessingState,
ProjectLink,
)
# -----------------------------------------------------------------
# Background
# -----------------------------------------------------------------
@given("I have a fresh plan lifecycle service for coverage boost")
def step_create_fresh_service(context: Context) -> None:
"""Create a clean PlanLifecycleService instance."""
Settings._instance = None
settings = Settings()
context.service = PlanLifecycleService(settings=settings)
context.error = None
# -----------------------------------------------------------------
# Helpers (not steps)
# -----------------------------------------------------------------
def _create_action(context: Context, name: str, **kwargs):
"""Helper to create a basic action."""
defaults = {
"name": name,
"description": f"Action {name}",
"definition_of_done": "Tests pass",
"strategy_actor": "openai/gpt-4",
"execution_actor": "openai/gpt-4",
}
defaults.update(kwargs)
return context.service.create_action(**defaults)
def _create_plan_in_phase(context: Context, target_phase: PlanPhase):
"""Create a plan and advance it to the given phase.
Returns the plan. Stores it on context.plan.
"""
action = _create_action(context, f"local/cov-{id(context)}")
plan = context.service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-1")],
)
pid = plan.identity.plan_id
if target_phase in (PlanPhase.EXECUTE, PlanPhase.APPLY):
context.service.start_strategize(pid)
context.service.complete_strategize(pid)
context.service.execute_plan(pid)
if target_phase == PlanPhase.APPLY:
context.service.start_execute(pid)
context.service.complete_execute(pid)
context.service.apply_plan(pid)
context.plan = context.service.get_plan(pid)
return context.plan
# =================================================================
# Scenario: get_action_by_name case-insensitive lookup (line 284)
# =================================================================
@given("an action stored under its normalised lowercase key")
def step_store_action_under_normalised_key(context: Context) -> None:
"""Create an action normally — it is stored under its lowercase key."""
action = _create_action(context, "local/scan-target")
context.expected_action = action
@when("I look up the action with mixed-case input via get_action_by_name")
def step_lookup_via_get_action_by_name(context: Context) -> None:
"""Call get_action_by_name with mixed-case input."""
context.found_action = context.service.get_action_by_name("Local/Scan-Target")
@then("the action should be found via case-insensitive lookup")
def step_verify_case_insensitive_found(context: Context) -> None:
"""Verify the action returned is the one we stored."""
assert context.found_action is not None
assert context.found_action is context.expected_action
assert str(context.found_action.namespaced_name) == "local/scan-target"
# =================================================================
# Scenario: use_action copies action.invariants (line 420)
# =================================================================
@given('an action with invariants "{inv1}" and "{inv2}"')
def step_create_action_with_invariants(context: Context, inv1: str, inv2: str) -> None:
"""Create an action whose ``invariants`` list is non-empty."""
context.action = _create_action(
context,
"local/inv-action",
invariants=[inv1, inv2],
)
@when("I use that action to create a plan")
def step_use_action_create_plan(context: Context) -> None:
"""Use the action to instantiate a plan."""
context.plan = context.service.use_action(
action_name=str(context.action.namespaced_name),
project_links=[ProjectLink(project_name="proj-inv")],
)
@then("the plan should contain {count:d} action-sourced invariants")
def step_check_action_sourced_invariant_count(context: Context, count: int) -> None:
"""Verify the plan has the expected number of ACTION-sourced invariants."""
action_invariants = [
inv for inv in context.plan.invariants if inv.source == InvariantSource.ACTION
]
assert len(action_invariants) == count, (
f"Expected {count} ACTION-sourced invariants, got {len(action_invariants)}"
)
@then('the invariant texts should include "{text}"')
def step_check_invariant_text_present(context: Context, text: str) -> None:
"""Verify a specific invariant text is present."""
texts = [inv.text for inv in context.plan.invariants]
assert text in texts, f"Expected '{text}' in invariant texts: {texts}"
# =================================================================
# Scenario: execute_plan InvalidPhaseTransitionError (line 643)
# =================================================================
@given("a plan that is already in execute phase")
def step_plan_already_in_execute(context: Context) -> None:
"""Create a plan already in Execute/QUEUED phase."""
_create_plan_in_phase(context, PlanPhase.EXECUTE)
@given("a plan that is already in apply phase")
def step_plan_already_in_apply(context: Context) -> None:
"""Create a plan already in Apply/QUEUED phase."""
_create_plan_in_phase(context, PlanPhase.APPLY)
@when("I try to execute the plan from execute phase")
def step_try_execute_from_execute(context: Context) -> None:
"""Attempt execute_plan when plan is already in Execute phase."""
context.error = None
try:
context.service.execute_plan(context.plan.identity.plan_id)
except InvalidPhaseTransitionError as e:
context.error = e
@when("I try to execute the plan from apply phase")
def step_try_execute_from_apply(context: Context) -> None:
"""Attempt execute_plan when plan is in Apply phase."""
context.error = None
try:
context.service.execute_plan(context.plan.identity.plan_id)
except InvalidPhaseTransitionError as e:
context.error = e
@then("an InvalidPhaseTransitionError should be raised for the coverage boost")
def step_verify_invalid_transition_error(context: Context) -> None:
"""Verify that an InvalidPhaseTransitionError was raised."""
assert context.error is not None, (
"Expected InvalidPhaseTransitionError but none raised"
)
assert isinstance(context.error, InvalidPhaseTransitionError), (
f"Expected InvalidPhaseTransitionError, got {type(context.error).__name__}"
)
# =================================================================
# Scenario: constrain_apply happy path (lines 829-846)
# =================================================================
@given("a plan in apply phase with processing state for coverage boost")
def step_plan_in_apply_processing(context: Context) -> None:
"""Create a plan in Apply/PROCESSING state."""
_create_plan_in_phase(context, PlanPhase.APPLY)
pid = context.plan.identity.plan_id
context.service.start_apply(pid)
context.plan = context.service.get_plan(pid)
@when('I constrain the apply with reason "{reason}" for coverage boost')
def step_constrain_apply(context: Context, reason: str) -> None:
"""Call constrain_apply on the plan."""
context.plan = context.service.constrain_apply(
context.plan.identity.plan_id, reason
)
@then('the plan processing state should be "{expected}" for coverage boost')
def step_check_processing_state_cb(context: Context, expected: str) -> None:
"""Verify the plan processing state."""
actual = context.plan.processing_state.value
assert actual == expected, f"Expected state '{expected}', got '{actual}'"
@then('the plan error message should be "{expected}" for coverage boost')
def step_check_error_message_cb(context: Context, expected: str) -> None:
"""Verify the plan error_message field."""
assert context.plan.error_message == expected, (
f"Expected error message '{expected}', got '{context.plan.error_message}'"
)
# =================================================================
# Scenario: constrain_apply wrong phase (line 831-832)
# =================================================================
@given("a plan in strategize phase for coverage boost")
def step_plan_in_strategize_for_cb(context: Context) -> None:
"""Create a plan that is still in Strategize phase."""
action = _create_action(context, "local/strat-only")
context.plan = context.service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-strat")],
)
@when('I try to constrain the apply with reason "{reason}"')
def step_try_constrain_apply(context: Context, reason: str) -> None:
"""Attempt constrain_apply expecting it to fail."""
context.error = None
try:
context.service.constrain_apply(context.plan.identity.plan_id, reason)
except PlanError as e:
context.error = e
@then("a PlanError should be raised for coverage boost")
def step_verify_plan_error_cb(context: Context) -> None:
"""Verify a PlanError was raised."""
assert context.error is not None, "Expected PlanError but none raised"
assert isinstance(context.error, PlanError), (
f"Expected PlanError, got {type(context.error).__name__}"
)
# =================================================================
# Scenario: auto_progress final return (line 959)
# =================================================================
@given("a plan in strategize queued state with full automation for coverage boost")
def step_plan_strategize_queued_full_auto(context: Context) -> None:
"""Create a plan in Strategize/QUEUED with FULL_AUTOMATION.
should_auto_progress will return False because the plan is
QUEUED (not COMPLETE), so auto_progress hits the early return
at line 935. To hit line 959 instead, we need
should_auto_progress to return True but neither if-block to match.
However, looking at the logic: should_auto_progress returns True
only when phase==STRATEGIZE+COMPLETE or phase==EXECUTE+COMPLETE.
If should_auto_progress is True, one of the two if-blocks in
auto_progress *will* match — unless the plan state changed between
the check and the if-blocks.
The simplest way to reach line 959 is for should_auto_progress
to return False, which makes auto_progress return at line 935.
But line 935 is already covered.
To truly hit line 959, we need should_auto_progress to return True
but the plan to NOT match either if-condition by the time we reach
them. We can do this by monkey-patching should_auto_progress.
"""
action = _create_action(context, "local/auto-prog-test")
context.plan = context.service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-auto")],
)
# Plan is now Strategize/QUEUED — should_auto_progress returns False,
# so we monkey-patch it to return True to force execution past line 935
# into the if-blocks, which won't match (QUEUED != COMPLETE).
context.service.should_auto_progress = lambda plan: True
@when("I call auto_progress on the plan for coverage boost")
def step_call_auto_progress(context: Context) -> None:
"""Call auto_progress."""
context.plan = context.service.auto_progress(context.plan.identity.plan_id)
@then("the plan should be returned unchanged in strategize queued state")
def step_verify_plan_unchanged(context: Context) -> None:
"""Verify the plan was returned without phase/state changes."""
assert context.plan.phase == PlanPhase.STRATEGIZE, (
f"Expected STRATEGIZE, got {context.plan.phase}"
)
assert context.plan.processing_state == ProcessingState.QUEUED, (
f"Expected QUEUED, got {context.plan.processing_state}"
)