Files
cleveragents-core/features/steps/circuit_breaker_coverage_steps.py
freemo 051ee7c290
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 47s
CI / security (pull_request) Successful in 52s
CI / build (pull_request) Successful in 56s
CI / e2e_tests (pull_request) Successful in 5m1s
CI / integration_tests (pull_request) Successful in 5m30s
CI / unit_tests (pull_request) Successful in 5m42s
CI / docker (pull_request) Successful in 58s
CI / coverage (pull_request) Successful in 7m35s
CI / build (push) Successful in 21s
CI / docker (push) Has been skipped
CI / benchmark-regression (pull_request) Failing after 49m24s
CI / lint (push) Successful in 22s
CI / quality (push) Successful in 39s
CI / security (push) Successful in 48s
CI / typecheck (push) Successful in 1m26s
CI / benchmark-regression (push) Has been skipped
CI / e2e_tests (push) Successful in 5m53s
CI / coverage (push) Successful in 9m4s
CI / benchmark-publish (push) Successful in 19m10s
CI / integration_tests (push) Failing after 19m18s
CI / unit_tests (push) Failing after 19m20s
test(coverage): add Behave BDD tests to improve coverage across 52 source files
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
2026-03-20 21:22:10 +00:00

279 lines
11 KiB
Python

"""Step definitions for circuit_breaker_coverage.feature.
These steps target specific uncovered lines in circuit_breaker.py:
- Lines 160, 162-163, 165-167, 169: sync `call()` — except CircuitBreakerOpen
handler during HALF_OPEN state (inner-service cascade prevention).
- Lines 191, 193-195: sync `call()` — except BaseException handler during
HALF_OPEN state (permit leak prevention for KeyboardInterrupt etc.).
- Lines 286, 288-289, 291-293, 295: async `async_call()` — except
CircuitBreakerOpen handler during HALF_OPEN (same as sync equivalent).
- Line 360: `_on_success()` — stale half-open generation discarded.
- Line 384: `_on_failure()` — no-op when circuit is already OPEN.
"""
import asyncio
import contextlib
import time
from behave import given, then, when
from cleveragents.core.circuit_breaker import (
CircuitBreaker,
CircuitBreakerOpen,
CircuitBreakerState,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the circuit breaker coverage module is imported")
def step_circuit_breaker_coverage_module_imported(context):
"""Verify the module is importable."""
assert CircuitBreaker is not None
assert CircuitBreakerOpen is not None
assert CircuitBreakerState is not None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _open_breaker(cb):
"""Drive the breaker into OPEN state by inducing enough failures."""
for _ in range(cb.failure_threshold):
with contextlib.suppress(RuntimeError):
cb.call(lambda: (_ for _ in ()).throw(RuntimeError("fail")))
assert cb.state == CircuitBreakerState.OPEN
def _transition_to_half_open(cb):
"""Open the breaker, wait for recovery_timeout, then trigger the
OPEN -> HALF_OPEN transition via a successful probe call.
After one success the breaker is still HALF_OPEN because it needs
half_open_max_successes=2 successes to close.
"""
_open_breaker(cb)
# Wait long enough for recovery_timeout + cooldown to elapse.
time.sleep(0.05)
# The next call transitions the breaker to HALF_OPEN internally
# and counts as the first success.
result = cb.call(lambda: "probe_ok")
assert result == "probe_ok"
assert cb.state == CircuitBreakerState.HALF_OPEN
# ---------------------------------------------------------------------------
# Given: create a circuit breaker with fast recovery
# ---------------------------------------------------------------------------
@given(
"a coverage circuit breaker with failure threshold {threshold:d} and fast recovery"
)
def step_create_coverage_fast_recovery_breaker(context, threshold):
"""Create a breaker that transitions quickly for testing."""
context.cb = CircuitBreaker(
failure_threshold=threshold,
recovery_timeout=0.01,
expected_exception=RuntimeError,
half_open_max_successes=2,
cooldown_seconds=0.0,
name="test-coverage",
)
@given("the coverage circuit breaker is transitioned to half-open state")
def step_coverage_transition_to_half_open(context):
"""Transition the breaker CLOSED -> OPEN -> HALF_OPEN."""
_transition_to_half_open(context.cb)
# Record the permits before the next call.
context.permits_before = context.cb._half_open_permits
# ---------------------------------------------------------------------------
# When: sync CircuitBreakerOpen from inner service (lines 160-169)
# ---------------------------------------------------------------------------
@when("I sync-call a function that raises inner CircuitBreakerOpen")
def step_sync_call_inner_cbo(context):
"""Call a function that raises CircuitBreakerOpen inside the breaker."""
def inner_raises_cbo():
raise CircuitBreakerOpen("inner service is open")
context.caught_exception = None
try:
context.cb.call(inner_raises_cbo)
except CircuitBreakerOpen as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When: sync BaseException (KeyboardInterrupt) during HALF_OPEN (lines 191-195)
# ---------------------------------------------------------------------------
@when("I sync-call a function that raises a KeyboardInterrupt")
def step_sync_call_keyboard_interrupt(context):
"""Call a function that raises KeyboardInterrupt (a BaseException)."""
def inner_raises_kbi():
raise KeyboardInterrupt("simulated ctrl-c")
context.caught_exception = None
try:
context.cb.call(inner_raises_kbi)
except KeyboardInterrupt as exc:
context.caught_exception = exc
# ---------------------------------------------------------------------------
# When: async CircuitBreakerOpen from inner service (lines 286-295)
# ---------------------------------------------------------------------------
@when("I async-call a function that raises inner CircuitBreakerOpen")
def step_async_call_inner_cbo(context):
"""Async-call a function that raises CircuitBreakerOpen."""
async def async_inner_raises_cbo():
raise CircuitBreakerOpen("inner async service is open")
context.caught_exception = None
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(context.cb.async_call(async_inner_raises_cbo))
except CircuitBreakerOpen as exc:
context.caught_exception = exc
finally:
loop.close()
# ---------------------------------------------------------------------------
# When: async BaseException (KeyboardInterrupt) during HALF_OPEN
# ---------------------------------------------------------------------------
@when("I async-call a function that raises a KeyboardInterrupt")
def step_async_call_keyboard_interrupt(context):
"""Async-call a function that raises KeyboardInterrupt."""
async def async_inner_raises_kbi():
raise KeyboardInterrupt("simulated async ctrl-c")
context.caught_exception = None
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(context.cb.async_call(async_inner_raises_kbi))
except KeyboardInterrupt as exc:
context.caught_exception = exc
finally:
loop.close()
# ---------------------------------------------------------------------------
# Then: verify exceptions
# ---------------------------------------------------------------------------
@then("the caught exception should be a CircuitBreakerOpen")
def step_verify_cbo_raised(context):
assert isinstance(context.caught_exception, CircuitBreakerOpen), (
f"Expected CircuitBreakerOpen, got {type(context.caught_exception).__name__}"
)
@then("the caught exception should be a KeyboardInterrupt")
def step_verify_kbi_raised(context):
assert isinstance(context.caught_exception, KeyboardInterrupt), (
f"Expected KeyboardInterrupt, got {type(context.caught_exception).__name__}"
)
@then("the coverage half-open permit count should be restored")
def step_verify_coverage_permit_restored(context):
"""After the exception handler restores the permit, the count should
be back to what it was before the call consumed it.
"""
assert context.cb._half_open_permits == context.permits_before, (
f"Expected permits={context.permits_before}, "
f"got {context.cb._half_open_permits}"
)
# ---------------------------------------------------------------------------
# Stale generation discarded in _on_success (line 360)
# ---------------------------------------------------------------------------
@given("a coverage circuit breaker in half-open state with generation {gen:d}")
def step_breaker_half_open_with_generation(context, gen):
"""Create a breaker and directly set it to HALF_OPEN with a known
generation counter so we can test stale-generation logic.
"""
context.cb = CircuitBreaker(
failure_threshold=3,
recovery_timeout=60.0,
expected_exception=RuntimeError,
half_open_max_successes=2,
name="stale-gen-test",
)
# Directly manipulate internal state for precise control.
context.cb.state = CircuitBreakerState.HALF_OPEN
context.cb._half_open_generation = gen
context.cb.success_count_half_open = 0
@when("_on_success is called with stale generation {stale_gen:d}")
def step_call_on_success_stale(context, stale_gen):
"""Call _on_success with a generation that does not match the current one."""
context.on_success_result = context.cb._on_success(generation=stale_gen)
@then("the _on_success result should be False")
def step_verify_on_success_returns_false(context):
assert context.on_success_result is False, (
f"Expected False, got {context.on_success_result}"
)
@then("the coverage circuit breaker should still be half-open")
def step_verify_still_half_open(context):
assert context.cb.state == CircuitBreakerState.HALF_OPEN, (
f"Expected HALF_OPEN, got {context.cb.state}"
)
# ---------------------------------------------------------------------------
# _on_failure when already OPEN (line 384)
# ---------------------------------------------------------------------------
@given("a coverage circuit breaker that is already in the open state")
def step_breaker_already_open(context):
"""Create a breaker and force it into the OPEN state."""
context.cb = CircuitBreaker(
failure_threshold=3,
recovery_timeout=60.0,
expected_exception=RuntimeError,
name="already-open-test",
)
context.cb.state = CircuitBreakerState.OPEN
context.cb.failure_count = 5
context.cb.last_failure_time = time.monotonic()
@when("_on_failure is called on the already-open breaker")
def step_call_on_failure_while_open(context):
"""Call _on_failure when the circuit is already OPEN."""
context.original_failure_count = context.cb.failure_count
context.on_failure_result = context.cb._on_failure(generation=0)
@then("the _on_failure result should be False")
def step_verify_on_failure_returns_false(context):
assert context.on_failure_result is False, (
f"Expected False, got {context.on_failure_result}"
)
@then("the coverage circuit breaker state should still be open")
def step_verify_still_open(context):
assert context.cb.state == CircuitBreakerState.OPEN, (
f"Expected OPEN, got {context.cb.state}"
)
@then("the failure count should not have changed")
def step_verify_failure_count_unchanged(context):
assert context.cb.failure_count == context.original_failure_count, (
f"Expected failure_count={context.original_failure_count}, "
f"got {context.cb.failure_count}"
)