forked from HAL9000/cleveragents-core
051ee7c290
Added 52 new .feature files and corresponding _steps.py files targeting previously uncovered code paths in the following areas: - TUI layer: app, commands, persona (state/schema/registry), widgets, input (shell_exec, reference_parser) - Application services: plan lifecycle/service/executor, session, project, repo indexing, correction, checkpoint, actor, llm_actors, strategy coordinator, resource file watcher, service retry wiring - CLI commands: session, resource, repl, plan, db, automation_profile - Domain models: retry_policy, resource_type, cost_budget, docker_compose_analyzer, detail_level, _sql_string_aware, _postgresql_helpers - Core: circuit_breaker, retry_service_patterns - Infrastructure: repositories, transaction_sandbox, strategy_registry, plugins/loader, container - Config: settings - Agents: plan_generation, context_analysis, auto_debug - A2A: facade All new tests follow the Behave/Gherkin BDD standard. Resolved step definition collisions with unique prefixes. Fixed Alembic fileConfig logger disabling issue (disable_existing_loggers=False). ISSUES CLOSED: #1068
479 lines
17 KiB
Python
479 lines
17 KiB
Python
"""Step definitions for service_retry_wiring_coverage.feature.
|
|
|
|
Targets uncovered lines in service_retry_wiring.py:
|
|
- Line 149, 220: _apply_settings_defaults with only_services parameter
|
|
- Lines 252-254: _apply_config_overrides inner TypeError fallback
|
|
- Line 262: _build_cached_wait with string backoff_strategy
|
|
- Line 303: _get_or_create_cb returns None when CB disabled
|
|
- Line 308: _get_or_create_cb double-check after lock
|
|
- Lines 385-387: execute() TypeError on async callable
|
|
- Line 407: execute() nesting guard with no CB
|
|
- Line 441: execute() retry loop with no CB
|
|
- Line 521: async_execute() nesting guard with no CB
|
|
- Line 553: async_execute() retry loop with no CB
|
|
- Line 625: wrap_service_method with string backoff_strategy
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.application.services import service_retry_wiring as srw_module
|
|
from cleveragents.application.services.service_retry_wiring import ServiceRetryWiring
|
|
from cleveragents.config.settings import Settings
|
|
from cleveragents.core.retry_patterns import _retry_depth
|
|
from cleveragents.domain.models.core.retry_policy import (
|
|
CircuitBreakerConfig,
|
|
RetryPolicyConfig,
|
|
RetryStrategy,
|
|
ServiceRetryPolicy,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
@given("the service retry wiring module is imported")
|
|
def step_module_imported(context):
|
|
"""Verify the module is importable."""
|
|
assert ServiceRetryWiring is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
def _make_settings_via_env(**env_overrides) -> Settings:
|
|
"""Create a Settings instance using environment variables for overrides.
|
|
|
|
pydantic-settings prioritises env vars over constructor kwargs, so we
|
|
must set env vars to override field defaults.
|
|
"""
|
|
env_map = {
|
|
"retry_max_attempts": "CLEVERAGENTS_RETRY_MAX_ATTEMPTS",
|
|
"retry_base_delay": "CLEVERAGENTS_RETRY_BASE_DELAY",
|
|
"retry_service_overrides": "CLEVERAGENTS_RETRY_SERVICE_OVERRIDES",
|
|
}
|
|
saved = {}
|
|
for key, value in env_overrides.items():
|
|
env_key = env_map.get(key, f"CLEVERAGENTS_{key.upper()}")
|
|
saved[env_key] = os.environ.get(env_key)
|
|
os.environ[env_key] = str(value)
|
|
|
|
try:
|
|
return Settings(env="test", mock_providers=True)
|
|
finally:
|
|
for env_key, old_val in saved.items():
|
|
if old_val is None:
|
|
os.environ.pop(env_key, None)
|
|
else:
|
|
os.environ[env_key] = old_val
|
|
|
|
|
|
def _make_default_settings() -> Settings:
|
|
"""Create a Settings instance with all defaults."""
|
|
return Settings(env="test", mock_providers=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 149, 220: Config overrides add new service, global defaults re-applied
|
|
# ---------------------------------------------------------------------------
|
|
@given("settings with a non-default retry_max_attempts of {n:d}")
|
|
def step_settings_non_default_max_attempts(context, n):
|
|
context.custom_max_attempts = n
|
|
|
|
|
|
@given('settings with retry_service_overrides introducing a new service "{svc_name}"')
|
|
def step_settings_with_new_service_override(context, svc_name):
|
|
overrides = {svc_name: {"retry": {"base_delay": 0.5, "max_delay": 5.0}}}
|
|
context.custom_settings = _make_settings_via_env(
|
|
retry_max_attempts=context.custom_max_attempts,
|
|
retry_service_overrides=json.dumps(overrides),
|
|
)
|
|
|
|
|
|
@when("I create a ServiceRetryWiring from those settings")
|
|
def step_create_wiring_from_settings(context):
|
|
context.wiring = ServiceRetryWiring(context.custom_settings)
|
|
|
|
|
|
@then('the policy for "{svc_name}" should have max_attempts of {n:d}')
|
|
def step_verify_policy_max_attempts(context, svc_name, n):
|
|
policy = context.wiring.get_policy(svc_name)
|
|
assert policy.retry.max_attempts == n, (
|
|
f"Expected max_attempts={n}, got {policy.retry.max_attempts}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 252-254: Invalid JSON + logger.warning raises TypeError
|
|
# ---------------------------------------------------------------------------
|
|
@given("settings with invalid JSON in retry_service_overrides")
|
|
def step_settings_invalid_json(context):
|
|
context.invalid_json_settings = _make_settings_via_env(
|
|
retry_service_overrides="{this is not valid json!!!}",
|
|
)
|
|
|
|
|
|
@given("a logger that raises TypeError on warning calls")
|
|
def step_patch_logger_typeerror(context):
|
|
"""Install a logger whose .warning() always raises TypeError."""
|
|
|
|
class TypeErrorLogger:
|
|
def debug(self, *a, **kw):
|
|
pass
|
|
|
|
def info(self, *a, **kw):
|
|
pass
|
|
|
|
def warning(self, *a, **kw):
|
|
raise TypeError("simulated logger TypeError")
|
|
|
|
def error(self, *a, **kw):
|
|
pass
|
|
|
|
context.original_logger = srw_module.logger
|
|
context.type_error_logger = TypeErrorLogger()
|
|
|
|
|
|
@when("I create a ServiceRetryWiring with the patched logger")
|
|
def step_create_wiring_patched_logger(context):
|
|
original = srw_module.logger
|
|
srw_module.logger = context.type_error_logger
|
|
try:
|
|
context.wiring = ServiceRetryWiring(context.invalid_json_settings)
|
|
context.construction_error = None
|
|
except Exception as exc:
|
|
context.construction_error = exc
|
|
finally:
|
|
srw_module.logger = original
|
|
|
|
|
|
@then("no exception should be raised during construction")
|
|
def step_verify_no_construction_error(context):
|
|
assert context.construction_error is None, (
|
|
f"Expected no error, got {context.construction_error!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 262: _build_cached_wait with plain string backoff_strategy
|
|
# ---------------------------------------------------------------------------
|
|
@given('a ServiceRetryPolicy with a plain string backoff_strategy "{strategy}"')
|
|
def step_policy_with_string_backoff(context, strategy):
|
|
policy = ServiceRetryPolicy(
|
|
service_name="string_backoff_svc",
|
|
retry=RetryPolicyConfig(
|
|
max_attempts=2,
|
|
base_delay=0.1,
|
|
max_delay=1.0,
|
|
backoff_strategy=RetryStrategy.EXPONENTIAL,
|
|
),
|
|
)
|
|
# Force the backoff_strategy to a plain string to bypass enum
|
|
object.__setattr__(policy.retry, "backoff_strategy", strategy)
|
|
context.string_backoff_policy = policy
|
|
|
|
|
|
@when("I call _build_cached_wait with the string-strategy policy")
|
|
def step_call_build_cached_wait(context):
|
|
context.wait_strategy = ServiceRetryWiring._build_cached_wait(
|
|
context.string_backoff_policy
|
|
)
|
|
|
|
|
|
@then("a valid wait strategy should be returned")
|
|
def step_verify_wait_strategy(context):
|
|
assert context.wait_strategy is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 303: _get_or_create_cb returns None when CB disabled
|
|
# ---------------------------------------------------------------------------
|
|
@given("a ServiceRetryWiring with a service that has circuit breaker disabled")
|
|
def step_wiring_with_disabled_cb(context):
|
|
settings = _make_default_settings()
|
|
context.wiring = ServiceRetryWiring(settings)
|
|
# Register a service with CB disabled
|
|
disabled_cb_policy = ServiceRetryPolicy(
|
|
service_name="no_cb_service",
|
|
retry=RetryPolicyConfig(max_attempts=2, base_delay=0.01, max_delay=0.1),
|
|
circuit_breaker=CircuitBreakerConfig(enabled=False),
|
|
)
|
|
context.wiring._registry.register(disabled_cb_policy)
|
|
|
|
|
|
@when("I call _get_or_create_cb for the disabled-CB service")
|
|
def step_call_get_or_create_cb_disabled(context):
|
|
context.cb_result = context.wiring._get_or_create_cb("no_cb_service")
|
|
|
|
|
|
@then("srwcov the result should be None")
|
|
def step_verify_cb_none(context):
|
|
assert context.cb_result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 308: _get_or_create_cb double-check path
|
|
# ---------------------------------------------------------------------------
|
|
@given("a ServiceRetryWiring with default settings")
|
|
def step_wiring_default_settings(context):
|
|
settings = _make_default_settings()
|
|
context.wiring = ServiceRetryWiring(settings)
|
|
|
|
|
|
@when("I concurrently request the circuit breaker for a new service with CB enabled")
|
|
def step_concurrent_cb_request(context):
|
|
"""Simulate the double-check-after-lock path by using threading to
|
|
insert a CB between the initial dict miss and the lock acquisition."""
|
|
svc_name = "double_check_svc"
|
|
# Ensure the service has a policy with CB enabled
|
|
policy = ServiceRetryPolicy(
|
|
service_name=svc_name,
|
|
retry=RetryPolicyConfig(max_attempts=2, base_delay=0.01, max_delay=0.1),
|
|
circuit_breaker=CircuitBreakerConfig(enabled=True),
|
|
)
|
|
context.wiring._registry.register(policy)
|
|
|
|
# First call creates the CB normally
|
|
cb1 = context.wiring._get_or_create_cb(svc_name)
|
|
assert cb1 is not None
|
|
|
|
# Now simulate the double-check race condition (line 308):
|
|
# Remove the CB from the fast dict, then use a thread to re-insert
|
|
# it before the lock-guarded second check in _get_or_create_cb.
|
|
existing_cb = context.wiring._circuit_breakers.pop(svc_name)
|
|
|
|
# Replace _cache_lock with a wrapper that injects the CB before
|
|
# the code inside the `with` block runs.
|
|
original_lock = context.wiring._cache_lock
|
|
|
|
class InjectingLock:
|
|
"""A lock wrapper that injects the CB after acquire but before
|
|
the inner code checks the dict again (line 306-308)."""
|
|
|
|
def __init__(self, real_lock, inject_fn):
|
|
self._real_lock = real_lock
|
|
self._inject_fn = inject_fn
|
|
|
|
def acquire(self, *args, **kwargs):
|
|
result = self._real_lock.acquire(*args, **kwargs)
|
|
self._inject_fn()
|
|
return result
|
|
|
|
def release(self, *args, **kwargs):
|
|
return self._real_lock.release(*args, **kwargs)
|
|
|
|
def __enter__(self):
|
|
self.acquire()
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
self.release()
|
|
|
|
def inject_cb():
|
|
context.wiring._circuit_breakers[svc_name] = existing_cb
|
|
|
|
context.wiring._cache_lock = InjectingLock(original_lock, inject_cb)
|
|
try:
|
|
result = context.wiring._get_or_create_cb(svc_name)
|
|
finally:
|
|
context.wiring._cache_lock = original_lock
|
|
|
|
context.double_check_cb = result
|
|
context.existing_cb = existing_cb
|
|
|
|
|
|
@then("the circuit breaker should be created once and cached")
|
|
def step_verify_cb_cached(context):
|
|
# The double-check path should return the pre-existing CB
|
|
assert context.double_check_cb is context.existing_cb
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lines 385-387: execute() with async callable raises TypeError
|
|
# ---------------------------------------------------------------------------
|
|
@given("an async callable function")
|
|
def step_async_callable(context):
|
|
async def async_fn():
|
|
return "async_result"
|
|
|
|
context.async_fn = async_fn
|
|
|
|
|
|
@when("I call sync execute with the async callable")
|
|
def step_call_sync_execute_with_async(context):
|
|
try:
|
|
context.wiring.execute(
|
|
service_name="plan_service",
|
|
operation_name="test_op",
|
|
func=context.async_fn,
|
|
)
|
|
context.execute_error = None
|
|
except TypeError as exc:
|
|
context.execute_error = exc
|
|
|
|
|
|
@then("a TypeError should be raised mentioning async_execute")
|
|
def step_verify_type_error(context):
|
|
assert context.execute_error is not None
|
|
assert "async_execute" in str(context.execute_error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 407: execute() nesting guard with no CB
|
|
# ---------------------------------------------------------------------------
|
|
@given("the retry nesting depth is at the maximum")
|
|
def step_set_max_nesting_depth(context):
|
|
"""Set the retry depth context var to the maximum nesting depth."""
|
|
context.depth_token = _retry_depth.set(srw_module.MAX_RETRY_NESTING_DEPTH)
|
|
|
|
def restore():
|
|
_retry_depth.reset(context.depth_token)
|
|
|
|
context.add_cleanup(restore)
|
|
|
|
|
|
@when("I call execute for the disabled-CB service with a simple function")
|
|
def step_execute_nesting_guard_no_cb(context):
|
|
def simple_fn():
|
|
return "nesting_guard_result"
|
|
|
|
context.execute_result = context.wiring.execute(
|
|
service_name="no_cb_service",
|
|
operation_name="test_op",
|
|
func=simple_fn,
|
|
)
|
|
|
|
|
|
@then("the function should be called directly without retry wrapping")
|
|
def step_verify_nesting_guard_result(context):
|
|
assert context.execute_result == "nesting_guard_result"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 441: execute() retry loop with no CB
|
|
# ---------------------------------------------------------------------------
|
|
@when("I call execute for the disabled-CB service with a simple function normally")
|
|
def step_execute_no_cb_normal(context):
|
|
def simple_fn():
|
|
return "no_cb_result"
|
|
|
|
context.execute_result = context.wiring.execute(
|
|
service_name="no_cb_service",
|
|
operation_name="test_op",
|
|
func=simple_fn,
|
|
)
|
|
|
|
|
|
@then("the function result should be returned successfully")
|
|
def step_verify_no_cb_result(context):
|
|
assert context.execute_result == "no_cb_result"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 521: async_execute() nesting guard with no CB
|
|
# ---------------------------------------------------------------------------
|
|
@when("I call async_execute for the disabled-CB service with an async function")
|
|
def step_async_execute_nesting_guard_no_cb(context):
|
|
async def async_fn():
|
|
return "async_nesting_guard_result"
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.async_result = loop.run_until_complete(
|
|
context.wiring.async_execute(
|
|
service_name="no_cb_service",
|
|
operation_name="test_op",
|
|
func=async_fn,
|
|
)
|
|
)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
@then("the async function should be called directly without retry wrapping")
|
|
def step_verify_async_nesting_guard_result(context):
|
|
assert context.async_result == "async_nesting_guard_result"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 553: async_execute() retry loop with no CB
|
|
# ---------------------------------------------------------------------------
|
|
@when(
|
|
"I call async_execute for the disabled-CB service with an async function normally"
|
|
)
|
|
def step_async_execute_no_cb_normal(context):
|
|
async def async_fn():
|
|
return "async_no_cb_result"
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.async_result = loop.run_until_complete(
|
|
context.wiring.async_execute(
|
|
service_name="no_cb_service",
|
|
operation_name="test_op",
|
|
func=async_fn,
|
|
)
|
|
)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
@then("the async function result should be returned successfully")
|
|
def step_verify_async_no_cb_result(context):
|
|
assert context.async_result == "async_no_cb_result"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line 625: wrap_service_method with string backoff_strategy
|
|
# ---------------------------------------------------------------------------
|
|
@given("a ServiceRetryWiring where a service has a plain string backoff_strategy")
|
|
def step_wiring_with_string_backoff(context):
|
|
settings = _make_default_settings()
|
|
context.wiring = ServiceRetryWiring(settings)
|
|
# Create a policy with a string backoff_strategy and CB disabled
|
|
# (CB disabled avoids the _cache_lock deadlock inside wrap_service_method).
|
|
policy = ServiceRetryPolicy(
|
|
service_name="str_backoff_wrap_svc",
|
|
retry=RetryPolicyConfig(
|
|
max_attempts=2,
|
|
base_delay=0.01,
|
|
max_delay=0.1,
|
|
backoff_strategy=RetryStrategy.EXPONENTIAL,
|
|
),
|
|
circuit_breaker=CircuitBreakerConfig(enabled=False),
|
|
)
|
|
# Force backoff_strategy to a plain string to exercise line 625
|
|
object.__setattr__(policy.retry, "backoff_strategy", "exponential")
|
|
context.string_policy_for_wrap = policy
|
|
|
|
|
|
@when("I call wrap_service_method for that service")
|
|
def step_call_wrap_service_method(context):
|
|
# Patch registry.get to return our policy with string backoff_strategy.
|
|
# We must patch at the registry level because registry.get() deep-copies
|
|
# the stored policy, which would re-validate and coerce the string back
|
|
# to the RetryStrategy enum.
|
|
original_get = context.wiring._registry.get
|
|
|
|
def patched_get(service_name):
|
|
if service_name == "str_backoff_wrap_svc":
|
|
return context.string_policy_for_wrap
|
|
return original_get(service_name)
|
|
|
|
context.wiring._registry.get = patched_get
|
|
try:
|
|
context.decorator = context.wiring.wrap_service_method(
|
|
service_name="str_backoff_wrap_svc",
|
|
operation_name="test_op",
|
|
)
|
|
finally:
|
|
context.wiring._registry.get = original_get
|
|
|
|
|
|
@then("a callable decorator should be returned")
|
|
def step_verify_decorator_returned(context):
|
|
assert callable(context.decorator)
|