720 lines
25 KiB
Python
720 lines
25 KiB
Python
"""Step definitions for error recovery pattern tests.
|
|
|
|
Uses mock objects for PlanLifecycleService to test error recording,
|
|
classification, recovery hints, and retry policy without needing a DB.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.error_recovery_service import (
|
|
ErrorRecoveryService,
|
|
)
|
|
from cleveragents.domain.models.core.error_recovery import (
|
|
ErrorCategory,
|
|
ErrorRecord,
|
|
classify_error,
|
|
get_recovery_hints,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
PlanPhase,
|
|
PlanTimestamps,
|
|
ProcessingState,
|
|
)
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PLAN_ID = "01ERRTEST000000000000001"
|
|
|
|
|
|
def _make_mock_plan(
|
|
*,
|
|
plan_id: str = _PLAN_ID,
|
|
phase: PlanPhase = PlanPhase.EXECUTE,
|
|
state: ProcessingState = ProcessingState.ERRORED,
|
|
is_terminal: bool = False,
|
|
) -> MagicMock:
|
|
"""Create a mock Plan object in errored state."""
|
|
plan = MagicMock()
|
|
plan.identity.plan_id = plan_id
|
|
plan.phase = phase
|
|
plan.processing_state = state
|
|
plan.is_terminal = is_terminal
|
|
plan.error_message = None
|
|
plan.error_details = None
|
|
plan.timestamps = PlanTimestamps()
|
|
return plan
|
|
|
|
|
|
def _make_service(
|
|
plan: MagicMock,
|
|
auto_retry_threshold: float = 0.0,
|
|
max_retries: int = 3,
|
|
) -> ErrorRecoveryService:
|
|
"""Create an ErrorRecoveryService with mocked lifecycle."""
|
|
lifecycle = MagicMock()
|
|
lifecycle.get_plan.return_value = plan
|
|
lifecycle._commit_plan = MagicMock()
|
|
return ErrorRecoveryService(
|
|
lifecycle_service=lifecycle,
|
|
auto_retry_threshold=auto_retry_threshold,
|
|
max_retries=max_retries,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("an error recovery test environment")
|
|
def step_given_err_env(context: Context) -> None:
|
|
context.er_plan_id = _PLAN_ID
|
|
context.er_plan = None
|
|
context.er_service = None
|
|
context.er_classified_category = None # ErrorCategory | None
|
|
context.er_hints = None # list[RecoveryHint] | None
|
|
context.er_recorded_error = None # ErrorRecord | None
|
|
context.er_formatted_output = None # str | None
|
|
context.er_error_record = None # ErrorRecord | None
|
|
context.er_auto_retry_threshold = 0.0
|
|
context.er_max_retries = 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan in errored state for error recovery")
|
|
def step_given_errored_plan(context: Context) -> None:
|
|
plan = _make_mock_plan()
|
|
context.er_plan = plan
|
|
context.er_service = _make_service(
|
|
plan,
|
|
auto_retry_threshold=context.er_auto_retry_threshold,
|
|
max_retries=context.er_max_retries,
|
|
)
|
|
|
|
|
|
@given("the auto retry threshold is {threshold}")
|
|
def step_given_auto_retry_threshold(context: Context, threshold: str) -> None:
|
|
context.er_auto_retry_threshold = float(threshold)
|
|
# Recreate service if plan already exists
|
|
if context.er_plan is not None:
|
|
context.er_service = _make_service(
|
|
context.er_plan,
|
|
auto_retry_threshold=float(threshold),
|
|
max_retries=context.er_max_retries,
|
|
)
|
|
|
|
|
|
@given("the max retries is {n}")
|
|
def step_given_max_retries(context: Context, n: str) -> None:
|
|
context.er_max_retries = int(n)
|
|
# Recreate service if plan already exists
|
|
if context.er_plan is not None:
|
|
context.er_service = _make_service(
|
|
context.er_plan,
|
|
auto_retry_threshold=context.er_auto_retry_threshold,
|
|
max_retries=int(n),
|
|
)
|
|
|
|
|
|
@given(
|
|
'an error record with category "{cat}" and retry count {rc} and max retries {mr}'
|
|
)
|
|
def step_given_error_record(context: Context, cat: str, rc: str, mr: str) -> None:
|
|
context.er_error_record = ErrorRecord(
|
|
error_id="ERR-TEST-001",
|
|
phase="execute",
|
|
category=ErrorCategory(cat),
|
|
message="test error",
|
|
retry_count=int(rc),
|
|
max_retries=int(mr),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I classify the error message "{message}"')
|
|
def step_when_classify_message(context: Context, message: str) -> None:
|
|
context.er_classified_category = classify_error(message)
|
|
|
|
|
|
@when('I classify error text "{message}" using exception type "{exc_type}"')
|
|
def step_when_classify_with_exc(context: Context, message: str, exc_type: str) -> None:
|
|
context.er_classified_category = classify_error(message, exception_type=exc_type)
|
|
|
|
|
|
@when('I get recovery hints for category "{cat}" and plan "{plan_id}"')
|
|
def step_when_get_hints(context: Context, cat: str, plan_id: str) -> None:
|
|
context.er_hints = get_recovery_hints(ErrorCategory(cat), plan_id)
|
|
|
|
|
|
@when('I record an error with phase "{phase}" and message "{message}"')
|
|
def step_when_record_error(context: Context, phase: str, message: str) -> None:
|
|
context.er_recorded_error = context.er_service.record_error(
|
|
plan_id=context.er_plan_id,
|
|
phase=phase,
|
|
message=message,
|
|
)
|
|
|
|
|
|
@when(
|
|
'I record a categorized error with phase "{phase}" '
|
|
'and message "{message}" and category "{cat}"'
|
|
)
|
|
def step_when_record_error_with_category(
|
|
context: Context, phase: str, message: str, cat: str
|
|
) -> None:
|
|
context.er_recorded_error = context.er_service.record_error(
|
|
plan_id=context.er_plan_id,
|
|
phase=phase,
|
|
message=message,
|
|
category=ErrorCategory(cat),
|
|
)
|
|
|
|
|
|
@when('I format the error output as "{fmt}"')
|
|
def step_when_format_output(context: Context, fmt: str) -> None:
|
|
context.er_formatted_output = context.er_service.format_error_output(
|
|
plan_id=context.er_plan_id,
|
|
fmt=fmt,
|
|
)
|
|
|
|
|
|
@when('I format error output as "{fmt}" for separate plan "{plan_id}"')
|
|
def step_when_format_output_separate_plan(
|
|
context: Context, fmt: str, plan_id: str
|
|
) -> None:
|
|
# Create a minimal service for the empty plan
|
|
if context.er_service is None:
|
|
plan = _make_mock_plan(plan_id=plan_id)
|
|
context.er_service = _make_service(plan)
|
|
context.er_formatted_output = context.er_service.format_error_output(
|
|
plan_id=plan_id,
|
|
fmt=fmt,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the classified error category should be "{expected}"')
|
|
def step_then_category(context: Context, expected: str) -> None:
|
|
assert context.er_classified_category is not None, "No classification result"
|
|
assert context.er_classified_category == ErrorCategory(expected), (
|
|
f"Expected category '{expected}', got '{context.er_classified_category.value}'"
|
|
)
|
|
|
|
|
|
@then('the first recovery hint action should be "{action}"')
|
|
def step_then_first_hint_action(context: Context, action: str) -> None:
|
|
assert context.er_hints is not None and len(context.er_hints) > 0, (
|
|
"No recovery hints"
|
|
)
|
|
first = sorted(context.er_hints, key=lambda h: h.priority)[0]
|
|
assert first.action.value == action, (
|
|
f"Expected action '{action}', got '{first.action.value}'"
|
|
)
|
|
|
|
|
|
@then('the first recovery hint should have CLI command containing "{text}"')
|
|
def step_then_first_hint_cli(context: Context, text: str) -> None:
|
|
assert context.er_hints is not None and len(context.er_hints) > 0
|
|
first = sorted(context.er_hints, key=lambda h: h.priority)[0]
|
|
assert first.cli_command is not None, "First hint has no CLI command"
|
|
assert text in first.cli_command, (
|
|
f"Expected CLI command to contain '{text}', got: {first.cli_command}"
|
|
)
|
|
|
|
|
|
@then('the recorded error should have category "{cat}"')
|
|
def step_then_recorded_category(context: Context, cat: str) -> None:
|
|
assert context.er_recorded_error is not None
|
|
assert context.er_recorded_error.category == ErrorCategory(cat), (
|
|
f"Expected '{cat}', got '{context.er_recorded_error.category.value}'"
|
|
)
|
|
|
|
|
|
@then('the recorded error should have phase "{phase}"')
|
|
def step_then_recorded_phase(context: Context, phase: str) -> None:
|
|
assert context.er_recorded_error is not None
|
|
assert context.er_recorded_error.phase == phase
|
|
|
|
|
|
@then("the recorded error should have recovery hints")
|
|
def step_then_recorded_has_hints(context: Context) -> None:
|
|
assert context.er_recorded_error is not None
|
|
assert len(context.er_recorded_error.recovery_hints) > 0, (
|
|
"Expected recovery hints, got none"
|
|
)
|
|
|
|
|
|
@then("the error history should have {n} record")
|
|
def step_then_history_count_singular(context: Context, n: str) -> None:
|
|
history = context.er_service.get_error_history(context.er_plan_id)
|
|
assert history.total_errors == int(n), (
|
|
f"Expected {n} records, got {history.total_errors}"
|
|
)
|
|
|
|
|
|
@then("the error history should have {n} records")
|
|
def step_then_history_count(context: Context, n: str) -> None:
|
|
history = context.er_service.get_error_history(context.er_plan_id)
|
|
assert history.total_errors == int(n), (
|
|
f"Expected {n} records, got {history.total_errors}"
|
|
)
|
|
|
|
|
|
@then("the latest error retry count should be {n}")
|
|
def step_then_latest_retry_count(context: Context, n: str) -> None:
|
|
latest = context.er_service.get_latest_error(context.er_plan_id)
|
|
assert latest is not None
|
|
assert latest.retry_count == int(n), (
|
|
f"Expected retry_count={n}, got {latest.retry_count}"
|
|
)
|
|
|
|
|
|
@then("the service should recommend retry")
|
|
def step_then_should_retry(context: Context) -> None:
|
|
assert context.er_service.should_retry(context.er_plan_id), (
|
|
"Expected should_retry=True"
|
|
)
|
|
|
|
|
|
@then("the service should recommend escalation")
|
|
def step_then_should_escalate(context: Context) -> None:
|
|
assert context.er_service.should_escalate(context.er_plan_id), (
|
|
"Expected should_escalate=True"
|
|
)
|
|
|
|
|
|
@then("the latest error should have retries exhausted")
|
|
def step_then_retries_exhausted(context: Context) -> None:
|
|
latest = context.er_service.get_latest_error(context.er_plan_id)
|
|
assert latest is not None
|
|
assert latest.retries_exhausted, (
|
|
f"Expected retries exhausted, "
|
|
f"got retry_count={latest.retry_count}, max={latest.max_retries}"
|
|
)
|
|
|
|
|
|
@then('the error history category breakdown should include "{cat}"')
|
|
def step_then_category_breakdown(context: Context, cat: str) -> None:
|
|
history = context.er_service.get_error_history(context.er_plan_id)
|
|
breakdown = history.errors_by_category()
|
|
assert cat in breakdown, f"Expected category '{cat}' in breakdown, got: {breakdown}"
|
|
|
|
|
|
@then('the error recovery output should contain "{text}"')
|
|
def step_then_output_contains(context: Context, text: str) -> None:
|
|
assert context.er_formatted_output is not None
|
|
assert text in context.er_formatted_output, (
|
|
f"Expected output to contain '{text}', got: {context.er_formatted_output[:200]}"
|
|
)
|
|
|
|
|
|
@then("the error record should be retriable")
|
|
def step_then_record_retriable(context: Context) -> None:
|
|
assert context.er_error_record is not None
|
|
assert context.er_error_record.is_retriable, "Expected is_retriable=True"
|
|
|
|
|
|
@then("the error record should not be retriable")
|
|
def step_then_record_not_retriable(context: Context) -> None:
|
|
assert context.er_error_record is not None
|
|
assert not context.er_error_record.is_retriable, "Expected is_retriable=False"
|
|
|
|
|
|
@then("the error record should have retries exhausted")
|
|
def step_then_record_exhausted(context: Context) -> None:
|
|
assert context.er_error_record is not None
|
|
assert context.er_error_record.retries_exhausted, "Expected retries_exhausted=True"
|
|
|
|
|
|
@then('the error record CLI dict should have key "{key}"')
|
|
def step_then_record_cli_dict_key(context: Context, key: str) -> None:
|
|
assert context.er_error_record is not None
|
|
cli_dict = context.er_error_record.to_cli_dict()
|
|
assert key in cli_dict, (
|
|
f"Expected key '{key}' in CLI dict, got keys: {list(cli_dict.keys())}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Additional coverage steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I record an error with exception for phase "{phase}" and message "{message}"')
|
|
def step_when_record_with_exception(context: Context, phase: str, message: str) -> None:
|
|
try:
|
|
raise ValueError("simulated error for stack trace")
|
|
except ValueError as exc:
|
|
context.er_recorded_error = context.er_service.record_error(
|
|
plan_id=context.er_plan_id,
|
|
phase=phase,
|
|
message=message,
|
|
exception=exc,
|
|
)
|
|
|
|
|
|
@when(
|
|
'I record an actor-error in phase "{phase}" with message "{message}" '
|
|
'actor "{actor}" tool "{tool}"'
|
|
)
|
|
def step_when_record_with_actor_tool(
|
|
context: Context, phase: str, message: str, actor: str, tool: str
|
|
) -> None:
|
|
context.er_recorded_error = context.er_service.record_error(
|
|
plan_id=context.er_plan_id,
|
|
phase=phase,
|
|
message=message,
|
|
actor=actor,
|
|
tool_call=tool,
|
|
)
|
|
|
|
|
|
@given('an error record with actor "{actor}" and tool "{tool}"')
|
|
def step_given_record_with_actor_tool(context: Context, actor: str, tool: str) -> None:
|
|
context.er_error_record = ErrorRecord(
|
|
error_id="ERR-ACTORTOOL-001",
|
|
phase="execute",
|
|
category=ErrorCategory.TRANSIENT,
|
|
message="test error with actor and tool",
|
|
actor=actor,
|
|
tool_call=tool,
|
|
retry_count=0,
|
|
max_retries=3,
|
|
)
|
|
|
|
|
|
@then("the recorded error should have a stack summary")
|
|
def step_then_has_stack_summary(context: Context) -> None:
|
|
assert context.er_recorded_error is not None
|
|
assert context.er_recorded_error.stack_summary is not None, (
|
|
"Expected stack_summary to be set"
|
|
)
|
|
assert len(context.er_recorded_error.stack_summary) > 0
|
|
|
|
|
|
@then("the service should not recommend retry for a clean plan")
|
|
def step_then_no_retry_clean(context: Context) -> None:
|
|
assert not context.er_service.should_retry(context.er_plan_id)
|
|
|
|
|
|
@then("the service should not recommend escalation for a clean plan")
|
|
def step_then_no_escalate_clean(context: Context) -> None:
|
|
assert not context.er_service.should_escalate(context.er_plan_id)
|
|
|
|
|
|
@then('the CLI recovery hints should contain "{text}"')
|
|
def step_then_cli_hints_contain(context: Context, text: str) -> None:
|
|
hints = context.er_service.format_recovery_hints_for_cli(context.er_plan_id)
|
|
assert text in hints, f"Expected CLI hints to contain '{text}', got: {hints[:200]}"
|
|
|
|
|
|
@then("the error history should not have retriable errors")
|
|
def step_then_no_retriable(context: Context) -> None:
|
|
history = context.er_service.get_error_history(context.er_plan_id)
|
|
assert not history.has_retriable, "Expected no retriable errors"
|
|
|
|
|
|
@then("the CLI recovery hints for a clean plan should be empty")
|
|
def step_then_clean_plan_no_hints(context: Context) -> None:
|
|
hints = context.er_service.format_recovery_hints_for_cli(context.er_plan_id)
|
|
assert hints == "", f"Expected empty hints, got: {hints}"
|
|
|
|
|
|
@given('an error record with stack summary "{summary}"')
|
|
def step_given_record_with_stack(context: Context, summary: str) -> None:
|
|
context.er_error_record = ErrorRecord(
|
|
error_id="ERR-STACK-001",
|
|
phase="execute",
|
|
category=ErrorCategory.TRANSIENT,
|
|
message="test error with stack",
|
|
stack_summary=summary,
|
|
retry_count=0,
|
|
max_retries=3,
|
|
)
|
|
|
|
|
|
@given("a policy with threshold {threshold} and max retries {mr}")
|
|
def step_given_policy(context: Context, threshold: str, mr: str) -> None:
|
|
from cleveragents.domain.models.core.error_recovery import (
|
|
ErrorRecoveryPolicy,
|
|
)
|
|
|
|
context.er_policy = ErrorRecoveryPolicy(
|
|
auto_retry_threshold=float(threshold),
|
|
max_retries=int(mr),
|
|
)
|
|
|
|
|
|
@then("the policy should not recommend retry")
|
|
def step_then_policy_no_retry(context: Context) -> None:
|
|
assert context.er_error_record is not None
|
|
assert not context.er_policy.should_retry(context.er_error_record), (
|
|
"Expected should_retry=False"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T1: Persistence failure path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the lifecycle service update_error_details raises an error")
|
|
def step_given_lifecycle_update_raises(context: Context) -> None:
|
|
context.er_service._lifecycle.update_error_details.side_effect = RuntimeError(
|
|
"DB connection lost"
|
|
)
|
|
|
|
|
|
@then("the error should still be recorded in memory")
|
|
def step_then_error_in_memory(context: Context) -> None:
|
|
history = context.er_service.get_error_history(context.er_plan_id)
|
|
assert history is not None
|
|
assert history.total_errors >= 1, "Expected at least 1 error in memory"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T2: Branch coverage for policy and format_recovery_output
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the policy should recommend escalation")
|
|
def step_then_policy_escalation(context: Context) -> None:
|
|
assert context.er_error_record is not None
|
|
assert context.er_policy.should_escalate(context.er_error_record), (
|
|
"Expected should_escalate=True"
|
|
)
|
|
|
|
|
|
@given(
|
|
'a hinted error record with category "{cat}" and retry count {rc}'
|
|
" and max retries {mr}"
|
|
)
|
|
def step_given_error_record_with_hints(
|
|
context: Context,
|
|
cat: str,
|
|
rc: str,
|
|
mr: str,
|
|
) -> None:
|
|
from cleveragents.domain.models.core.error_recovery import (
|
|
RecoveryAction,
|
|
RecoveryHint,
|
|
)
|
|
|
|
hints = [
|
|
RecoveryHint(
|
|
action=RecoveryAction.RETRY,
|
|
message="Retry the operation",
|
|
cli_command="agents plan prompt TESTPLAN",
|
|
priority=0,
|
|
),
|
|
]
|
|
context.er_error_record = ErrorRecord(
|
|
error_id="ERR-HINT-001",
|
|
phase="execute",
|
|
category=ErrorCategory(cat),
|
|
message="test error with hints",
|
|
retry_count=int(rc),
|
|
max_retries=int(mr),
|
|
recovery_hints=hints,
|
|
)
|
|
|
|
|
|
@then("the formatted recovery output should contain CLI command text")
|
|
def step_then_format_contains_cli_cmd(context: Context) -> None:
|
|
output = context.er_policy.format_recovery_output(context.er_error_record)
|
|
assert "$ agents plan prompt" in output, (
|
|
f"Expected CLI command in output, got: {output}"
|
|
)
|
|
|
|
|
|
@then('the formatted recovery output should contain "{text}"')
|
|
def step_then_format_contains_text(context: Context, text: str) -> None:
|
|
output = context.er_policy.format_recovery_output(context.er_error_record)
|
|
assert text.lower() in output.lower(), f"Expected '{text}' in output, got: {output}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T4: Integration test with executor retry loop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a plan executor with error recovery service")
|
|
def step_given_executor_with_recovery(context: Context) -> None:
|
|
from cleveragents.application.services.plan_executor import PlanExecutor
|
|
|
|
# Build mock lifecycle service
|
|
lifecycle = MagicMock()
|
|
plan = _make_mock_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
|
|
# Ensure real enum values (not MagicMock proxies)
|
|
plan.phase = PlanPhase.EXECUTE
|
|
plan.state = ProcessingState.QUEUED
|
|
plan.decision_root_id = "01DECROOT00000000000001"
|
|
plan.definition_of_done = "- Step one\n- Step two"
|
|
lifecycle.get_plan.return_value = plan
|
|
lifecycle.update_error_details = MagicMock()
|
|
lifecycle._commit_plan = MagicMock()
|
|
|
|
# Build error recovery service wrapping the same lifecycle
|
|
er_service = ErrorRecoveryService(
|
|
lifecycle_service=lifecycle,
|
|
auto_retry_threshold=0.0,
|
|
max_retries=3,
|
|
)
|
|
|
|
executor = PlanExecutor(
|
|
lifecycle_service=lifecycle,
|
|
error_recovery_service=er_service,
|
|
)
|
|
|
|
context.er_executor = executor
|
|
context.er_executor_lifecycle = lifecycle
|
|
context.er_executor_recovery = er_service
|
|
context.er_executor_plan_id = plan.identity.plan_id
|
|
|
|
|
|
@given("the execute actor fails twice then succeeds")
|
|
def step_given_actor_fails_twice(context: Context) -> None:
|
|
call_count = {"n": 0}
|
|
|
|
def _side_effect(**kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] <= 2:
|
|
raise RuntimeError("Connection timed out — transient failure")
|
|
# Return a mock result on 3rd call
|
|
result = MagicMock()
|
|
result.changeset_id = "CS-001"
|
|
result.sandbox_refs = []
|
|
result.tool_calls_count = 1
|
|
return result
|
|
|
|
context.er_executor._execute_actor.execute = MagicMock(side_effect=_side_effect)
|
|
|
|
|
|
@when("I run the executor for the plan")
|
|
def step_when_run_executor(context: Context) -> None:
|
|
result = context.er_executor.run_execute(context.er_executor_plan_id)
|
|
context.er_executor_result = result
|
|
|
|
|
|
@then("the execute should complete after retries")
|
|
def step_then_execute_completed(context: Context) -> None:
|
|
assert context.er_executor_result is not None
|
|
context.er_executor_lifecycle.complete_execute.assert_called_once()
|
|
|
|
|
|
@then("the error recovery service should have recorded {n} errors")
|
|
def step_then_recovery_recorded_n(context: Context, n: str) -> None:
|
|
history = context.er_executor_recovery.get_error_history(
|
|
context.er_executor_plan_id,
|
|
)
|
|
assert history.total_errors == int(n), (
|
|
f"Expected {n} errors, got {history.total_errors}"
|
|
)
|
|
|
|
|
|
@given("a plan executor with error recovery service max retries {n}")
|
|
def step_given_executor_max_retries(context: Context, n: str) -> None:
|
|
from cleveragents.application.services.plan_executor import PlanExecutor
|
|
|
|
lifecycle = MagicMock()
|
|
plan = _make_mock_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED)
|
|
plan.phase = PlanPhase.EXECUTE
|
|
plan.state = ProcessingState.QUEUED
|
|
plan.decision_root_id = "01DECROOT00000000000001"
|
|
plan.definition_of_done = "- Step one"
|
|
lifecycle.get_plan.return_value = plan
|
|
lifecycle.update_error_details = MagicMock()
|
|
lifecycle._commit_plan = MagicMock()
|
|
|
|
er_service = ErrorRecoveryService(
|
|
lifecycle_service=lifecycle,
|
|
auto_retry_threshold=0.0,
|
|
max_retries=int(n),
|
|
)
|
|
|
|
executor = PlanExecutor(
|
|
lifecycle_service=lifecycle,
|
|
error_recovery_service=er_service,
|
|
)
|
|
|
|
context.er_executor = executor
|
|
context.er_executor_lifecycle = lifecycle
|
|
context.er_executor_recovery = er_service
|
|
context.er_executor_plan_id = plan.identity.plan_id
|
|
|
|
|
|
@given('the execute actor always fails with "{msg}"')
|
|
def step_given_actor_always_fails(context: Context, msg: str) -> None:
|
|
context.er_executor._execute_actor.execute = MagicMock(
|
|
side_effect=RuntimeError(f"Connection timed out — {msg}"),
|
|
)
|
|
|
|
|
|
@when("I run the executor expecting failure")
|
|
def step_when_run_executor_failure(context: Context) -> None:
|
|
try:
|
|
context.er_executor.run_execute(context.er_executor_plan_id)
|
|
context.er_executor_raised = False
|
|
except RuntimeError:
|
|
context.er_executor_raised = True
|
|
|
|
|
|
@then("the lifecycle should record the plan as failed")
|
|
def step_then_plan_failed(context: Context) -> None:
|
|
assert context.er_executor_raised is True
|
|
context.er_executor_lifecycle.fail_execute.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# B5: Resource conflict classification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the classified error category should not be "{category}"')
|
|
def step_then_category_not_eq(context: Context, category: str) -> None:
|
|
assert context.er_classified_category is not None
|
|
assert context.er_classified_category.value != category, (
|
|
f"Expected category != '{category}', "
|
|
f"got '{context.er_classified_category.value}'"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# S1: Safe format-string substitution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I get recovery hints for category "{cat}" with plan_id "{pid}"')
|
|
def step_when_hints_with_braces(context: Context, cat: str, pid: str) -> None:
|
|
context.er_hints = get_recovery_hints(ErrorCategory(cat), pid)
|
|
|
|
|
|
@then('the hints should contain "{text}" in CLI commands')
|
|
def step_then_hints_contain_text(context: Context, text: str) -> None:
|
|
cli_cmds = [h.cli_command for h in context.er_hints if h.cli_command]
|
|
assert any(text in cmd for cmd in cli_cmds), (
|
|
f"Expected '{text}' in CLI commands, got: {cli_cmds}"
|
|
)
|