Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa9af80510 | |||
| 5ee08ea946 | |||
| 6e1646d565 | |||
| 815f546bd2 |
+7
-2
@@ -192,8 +192,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Added
|
||||
|
||||
- **Comprehensive test levels for async_cleanup module** (#10958): Added extensive Behave BDD scenarios and pytest unit tests covering all `AsyncResourceTracker` methods — registration, validation errors, concurrency, timeout handling, leak detection via finalizers, async context manager protocol, idempotent close operations, and mixed error/timeout paths. Tests span 7 coverage levels (smoke → advanced edge cases).
|
||||
|
||||
- `agents actor context clear` command to reset actor message history and
|
||||
state while preserving the underlying context directory via `ContextManager`
|
||||
(#6370).
|
||||
@@ -443,6 +441,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
MCP logger thread-safety in `session.py` using a threading lock. Created
|
||||
migration guide for users transitioning from legacy to V3 workflow.
|
||||
|
||||
- **Plan Tree JSON/YAML Command Envelope** (#9163): `agents plan tree --format json/yaml`
|
||||
now wraps output in the spec-required command envelope with `command`, `status`,
|
||||
`exit_code`, `data`, `timing`, and `messages` fields. The `data` field contains
|
||||
`plan_id`, `tree`, `summary` (nodes, depth, child_plans, invariants, superseded),
|
||||
`child_plans` list, and `decision_ids` mapping. Timing now reflects actual elapsed
|
||||
milliseconds from command start to envelope construction.
|
||||
|
||||
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
|
||||
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
|
||||
automation profile name is not a known built-in profile, instead of silently
|
||||
|
||||
+2
-6
@@ -17,13 +17,12 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
<<<<<<< HEAD
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix (#7875 / PR #7957): updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* Jeffrey Phillips Freeman has contributed the complete AUTO-BUG-POOL to AUTO-BUG-SUP tracking prefix fix across agent-system-specification.md, automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
* HAL 9000 has contributed the benchmark workflow separation (#9040): moved the benchmark-regression job out of the default PR workflow into a dedicated scheduled workflow, reducing median PR CI turnaround time from 99-132 minutes to under 30 minutes.
|
||||
* HAL 9000 has contributed the agent-evolution-pool-supervisor PR metadata assignment (#7888): the supervisor now automatically looks up the Type/Automation label and earliest open milestone before dispatching improvement PR creation workers, ensuring all generated improvement PRs have correct Type labels and milestone assignments.
|
||||
* HAL 9000 has contributed the decision recording hook for the Strategize phase (issue #8522): captures every decision point with question, chosen option, alternatives, confidence, rationale, and full context snapshot for replay and correction.
|
||||
* HAL 9000 has contributed automated specification maintenance, documentation updates, and bot-driven PR authorship.
|
||||
* HAL 9000 has contributed the plan tree JSON/YAML command envelope fix (#9163): wrapped `agents plan tree --format json/yaml` output in the spec-required command envelope structure, added summary statistics, decision_ids mapping, child_plans list, and accurate timing measurement.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
|
||||
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
|
||||
@@ -40,6 +39,3 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase.
|
||||
* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata.
|
||||
* HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568).
|
||||
* HAL 9000 has contributed comprehensive test levels for the async_cleanup module (PR #10958): added extensive Behave BDD scenarios and pytest unit tests covering all AsyncResourceTracker code paths including registration, validation, concurrency, timeout, leak detection, context manager protocol, and idempotent operations across 7 coverage levels.
|
||||
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
Feature: Comprehensive AsyncResourceTracker Test Levels
|
||||
As a developer
|
||||
I want comprehensive test coverage for the async_cleanup module
|
||||
So that all code paths are verified across 7 coverage levels
|
||||
|
||||
Background:
|
||||
Given I have an async resource tracker
|
||||
|
||||
# ========================== LEVEL 1 — SMOKE TESTS ==========================
|
||||
|
||||
@smoke
|
||||
Scenario Outline: Smoke — register and close a single valid resource via register/close_all
|
||||
Given I have a mock async resource named "<resource_name>" that closes successfully
|
||||
When I register the resource with name "<resource_name>"
|
||||
And I close all tracked resources with timeout 30.0
|
||||
Then the resource "<resource_name>" should be closed
|
||||
And the tracker open_count should be zero
|
||||
|
||||
Examples:
|
||||
| resource_name |
|
||||
| db-pool |
|
||||
| cache-conn |
|
||||
| file-handle |
|
||||
|
||||
@smoke
|
||||
Scenario Outline: Smoke — register None raises ValueError mentioning "resource"
|
||||
When I try to register a <resource_type> resource with name "<name>"
|
||||
Then a ValueError should be raised
|
||||
|
||||
Examples:
|
||||
| resource_type | name |
|
||||
| None | valid-res |
|
||||
|
||||
|
||||
# ========================== LEVEL 2 — REGISTRATION VALIDATION ==========================
|
||||
|
||||
@validation
|
||||
Scenario: Registration — empty string name raises ValueError mentioning "name"
|
||||
When I try to register a mock async resource with an empty string name
|
||||
Then a ValueError should be raised mentioning "name"
|
||||
|
||||
@validation
|
||||
Scenario: Registration — whitespace-only name is accepted (non-empty in Python)
|
||||
When I register a mock async resource with name "<whitespace_name>"
|
||||
Then no exception should be raised and open_count should be 1
|
||||
|
||||
Examples:
|
||||
| whitespace_name |
|
||||
| " " |
|
||||
| "\t\n" |
|
||||
|
||||
@validation
|
||||
Scenario: Registration — duplicate name raises ValueError mentioning the name
|
||||
Given I have a mock async resource named "dup-resource"
|
||||
When I register the resource with name "dup-resource"
|
||||
And I try to register another resource with the same name "dup-resource"
|
||||
Then a ValueError should be raised mentioning "dup-resource"
|
||||
|
||||
@validation
|
||||
Scenario: Registration — registering after close_all raises RuntimeError mentioning "closed"
|
||||
Given I have a mock async resource named "pre-close-res"
|
||||
When I register the resource with name "pre-close-res"
|
||||
And I close all tracked resources
|
||||
And I try to register a new resource named "post-close"
|
||||
Then a RuntimeError should be raised mentioning "closed"
|
||||
|
||||
@validation
|
||||
Scenario: Registration — multiple distinct resources can be registered concurrently
|
||||
Given I have mock async resources named "res-1", "res-2", and "res-3"
|
||||
When I register all three resources with their respective names
|
||||
Then the tracker open_count should be 3
|
||||
|
||||
|
||||
# ========================== LEVEL 3 — CONCURRENT/THREAD SAFETY ==========================
|
||||
|
||||
@concurrency
|
||||
Scenario: Concurrency — multiple concurrent registrations are thread-safe
|
||||
Given I have 5 mock async resources named "thread-res-1" through "thread-res-5"
|
||||
When I register all five resources concurrently from different threads
|
||||
Then the tracker open_count should be 5
|
||||
|
||||
|
||||
# ========================== LEVEL 4 — TIMEOUT AND CANCELLATION HANDLING ==========================
|
||||
|
||||
@timeout
|
||||
Scenario: Timeouts — resource exceeding timeout is logged and added to timed_out_resources
|
||||
Given I have a mock async resource named "slow-resource" that takes 10 seconds to close
|
||||
When I register the resource with name "slow-resource"
|
||||
And I close all tracked resources with a 0.5 second timeout
|
||||
Then the resource should be logged as timing out
|
||||
And timed_out_resources should contain "slow-resource"
|
||||
|
||||
@timeout
|
||||
Scenario: Timeouts — zero timeout causes immediate TimeoutError for slow resources
|
||||
Given I have a mock async resource named "very-slow-res" that takes 5 seconds to close
|
||||
When I register the resource with name "very-slow-res"
|
||||
And I close all tracked resources with a 0.0 second timeout
|
||||
Then timed_out_resources should contain "very-slow-res"
|
||||
|
||||
@timeout
|
||||
Scenario: Timeouts — mixed scenario: some fast, some slow — only slow ones time out
|
||||
Given I have 3 mock async resources:
|
||||
| name | close_delay_seconds |
|
||||
| fast-1 | 0.0 |
|
||||
| fast-2 | 0.0 |
|
||||
| slow-resource | 5.0 |
|
||||
When I register all three resources
|
||||
And I close all tracked resources with a 0.1 second timeout
|
||||
Then fast-1 should be closed normally
|
||||
And fast-2 should be closed normally
|
||||
And timed_out_resources should contain "slow-resource"
|
||||
|
||||
@timeout
|
||||
Scenario: Timeouts — resource closing exception is caught (not TimeoutError) and logged
|
||||
Given I have a mock async resource named "error-resource" that raises ValueError on close
|
||||
When I register the resource with name "error-resource"
|
||||
And I close all tracked resources with a 30.0 second timeout
|
||||
Then the tracker should handle the closing error without crashing
|
||||
And exception details should be logged for "error-resource"
|
||||
|
||||
@timeout
|
||||
Scenario: Timeouts — asyncio.CancelledError during close is caught and logged
|
||||
Given I have a mock async resource named "cancelled-res" that raises CancelledError on close
|
||||
When I register the resource with name "cancelled-res"
|
||||
And I close all tracked resources with a 30.0 second timeout
|
||||
Then the tracker should handle the cancellation without crashing
|
||||
And exception details should be logged for "cancelled-res"
|
||||
|
||||
@timeout
|
||||
Scenario: Timeouts — empty list of timed_out_resources after all resources close normally
|
||||
Given I have a mock async resource named "fast-closer" that closes instantly
|
||||
When I register the resource with name "fast-closer"
|
||||
And I close all tracked resources with a 30.0 second timeout
|
||||
Then timed_out_resources should be an empty list
|
||||
|
||||
|
||||
# ========================== LEVEL 5 — IDIOMATIC CLOSE / IDEMPOTENCY ==========================
|
||||
|
||||
@idempotent
|
||||
Scenario: Idempotency — calling close_all twice is safe and no-ops on second call
|
||||
Given I have a mock async resource named "once-resource"
|
||||
When I register the resource with name "once-resource"
|
||||
And I close all tracked resources once
|
||||
And I close all tracked resources a second time
|
||||
Then the resource should be closed exactly once
|
||||
|
||||
@idempotent
|
||||
Scenario: Idempotency — open_count is zero after double close_all
|
||||
Given I have a mock async resource named "double-close-res"
|
||||
When I register the resource with name "double-close-res"
|
||||
And I close all tracked resources twice
|
||||
Then the tracker open_count should be zero
|
||||
|
||||
@idempotent
|
||||
Scenario: Idempotency — close_all on empty tracker is safe no-op
|
||||
Given I have an async resource tracker with zero registered resources
|
||||
When I close all tracked resources
|
||||
Then no exception should be raised
|
||||
And timed_out_resources should be an empty list
|
||||
|
||||
|
||||
# ========================== LEVEL 6 — LEAK DETECTION / FINALIZER ==========================
|
||||
|
||||
@leak-detection
|
||||
Scenario: Leak detection — __del__ warns when tracker still has open resources
|
||||
Given I have a mock async resource named "leaky-conn"
|
||||
And I register the resource with name "leaky-conn"
|
||||
When the tracker is garbage collected without calling close_all
|
||||
Then a leak warning should be logged mentioning "leaky-conn"
|
||||
|
||||
@leak-detection
|
||||
Scenario: Leak detection — empty resources dict after close_all means no finalizer warning
|
||||
Given I have a mock async resource named "properly-closed-res"
|
||||
When I register the resource with name "properly-closed-res"
|
||||
And I close all tracked resources
|
||||
Then the tracker should report zero open resources on finalization and no leak warnings
|
||||
|
||||
@leak-detection
|
||||
Scenario: Leak detection — partial leaks (some closed, some not) show individual warnings
|
||||
Given I have a mock async resource named "closed-res-A" that closes successfully
|
||||
And I have another mock async resource named "leaky-res-B"
|
||||
When I register both resources with names "closed-res-A" and "leaky-res-B"
|
||||
And I close all tracked resources with timeout 0.1 (only res-B times out)
|
||||
Then timed_out_resources should contain "leaky-res-B"
|
||||
|
||||
|
||||
# ========================== LEVEL 7 — ASYNC CONTEXT MANAGER PROTOCOL ==========================
|
||||
|
||||
@context-manager
|
||||
Scenario: Context manager — __aenter__ returns self
|
||||
When I enter the tracker as an async context manager
|
||||
Then the returned object should be the same tracker instance
|
||||
|
||||
@context-manager
|
||||
Scenario: Context manager — __aexit__ calls close_all on exit (no exception)
|
||||
Given I have a mock async resource named "ctx-res-enter"
|
||||
When I use the tracker as an async context manager and register the resource
|
||||
And I exit the context manager successfully
|
||||
Then the resource "ctx-res-enter" should be closed
|
||||
|
||||
@context-manager
|
||||
Scenario: Context manager — __aexit__ propagates exception (returns False)
|
||||
Given I have a mock async resource named "ctx-exception-tracked"
|
||||
When I use the tracker as an async context manager with an outgoing exception
|
||||
Then the tracker still calls close_all even when an exception occurs
|
||||
|
||||
@context-manager
|
||||
Scenario: Context manager — __aexit__ sets no suppression (returns False)
|
||||
Given I have a mock async resource named "no-suppress-res"
|
||||
When I exit the context manager and check the result
|
||||
Then __aexit__ should return False (exception is not suppressed)
|
||||
|
||||
|
||||
# ========================== LEVEL 8 — OPEN COUNT QUERY ==========================
|
||||
|
||||
@query
|
||||
Scenario: open_count — reports zero before any registration
|
||||
Given I have an empty async resource tracker
|
||||
Then the tracker open_count should be zero
|
||||
|
||||
@query
|
||||
Scenario: open_count — incremented by one per successful registration
|
||||
Given I have 3 new mock async resources named "count-1", "count-2", and "count-3"
|
||||
When I register all three resources
|
||||
Then the tracker open_count should be 3
|
||||
|
||||
@query
|
||||
Scenario: open_count — decremented after close_all even if some resources failed/cancelled
|
||||
Given I have 2 mock async resources:
|
||||
| name | close_delay_seconds |
|
||||
| q-fast | 0.0 |
|
||||
| q-slow | 5.0 |
|
||||
When I register both resources
|
||||
And I close all with timeout 0.1
|
||||
Then the tracker open_count should be zero
|
||||
|
||||
|
||||
# ========================== LEVEL 9 — EDGE CASES AND ERROR HANDLING ==========================
|
||||
|
||||
@edge-case
|
||||
Scenario: Edge case — register with numeric-looking name string (e.g. "123")
|
||||
When I register a mock async resource with name "123"
|
||||
Then no exception should be raised and open_count should be 1
|
||||
|
||||
@edge-case
|
||||
Scenario: Edge case — very long resource names are accepted
|
||||
When I register a mock async resource with a very long name of 500 characters
|
||||
Then no exception should be raised and open_count should be 1
|
||||
|
||||
@edge-case
|
||||
Scenario: Edge case — close_all with unusually large timeout (1e9) still works
|
||||
Given I have a mock async resource named "long-timeout-res"
|
||||
When I register the resource with name "long-timeout-res"
|
||||
And I close all tracked resources with a 999999.0 second timeout
|
||||
Then the resource should be closed normally
|
||||
@@ -107,12 +107,12 @@ Feature: Plan explain and decision tree CLI commands
|
||||
# plan tree - json format
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@tdd_issue @tdd_issue_4254 @tdd_expected_fail
|
||||
@tdd_issue @tdd_issue_4254
|
||||
Scenario: Tree with json format
|
||||
Given a set of test decisions forming a tree
|
||||
When I format the tree as json
|
||||
Then the json tree output should be valid json
|
||||
And the json tree output should contain "decision_id"
|
||||
Then the json tree output should be a valid json envelope
|
||||
And the json tree output should contain "command"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# plan tree - yaml format
|
||||
|
||||
@@ -65,13 +65,13 @@ Feature: Plan explain and tree CLI command coverage
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
When pec I invoke "tree" with format "json"
|
||||
Then pec the exit code should be 0
|
||||
And pec the output should be valid json list
|
||||
And pec the output should be valid json envelope
|
||||
|
||||
Scenario: Tree CLI renders yaml format
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
When pec I invoke "tree" with format "yaml"
|
||||
Then pec the exit code should be 0
|
||||
And pec the output should contain "decision_id:"
|
||||
And pec the output should contain "command:"
|
||||
|
||||
Scenario: Tree CLI renders table format
|
||||
Given pec a mock DecisionService returning a list of decisions
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Step definitions for ACMS context analysis (stub)."""
|
||||
|
||||
from behave import given, then, when
|
||||
@@ -1,966 +0,0 @@
|
||||
"""Step definitions for comprehensive AsyncResourceTracker BDD scenarios."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import shared helpers (conventions mirroring other step modules)
|
||||
# ---------------------------------------------------------------------------
|
||||
from behave import given, then, when # noqa: F401 — Behave decorators
|
||||
|
||||
|
||||
class MockAsyncResource:
|
||||
"""Lightweight async resource whose close() behaviour is controllable."""
|
||||
|
||||
_close_counter: dict[str, int] = {}
|
||||
|
||||
def __init__(self, name: str, close_delay: float = 0.0, raise_on_close: Any | None = None) -> None:
|
||||
self.name = name
|
||||
self.close_delay = close_delay
|
||||
self.raise_on_close = raise_on_close
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
async def close(self) -> None: # noqa: D102 — implements AsyncResource protocol
|
||||
await asyncio.sleep(self.close_delay)
|
||||
if self.raise_on_close:
|
||||
raise self.raise_on_close
|
||||
self._closed = True
|
||||
|
||||
|
||||
# Track log handler used by scenario logging assertions.
|
||||
context_log_entries: list[str] = []
|
||||
|
||||
# Per-scenario store for registered names and timed-out resources context_names
|
||||
_context_names: list[str] = []
|
||||
_context_timed_out: list[str] = []
|
||||
_context_exceptions_logged: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_async_resources: dict[str, MockAsyncResource] = {}
|
||||
_current_tracker_ref: Any | None = None
|
||||
|
||||
|
||||
def _new_tracker():
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
tracker = AsyncResourceTracker()
|
||||
return tracker
|
||||
|
||||
|
||||
async def _register_and_close_scenario(
|
||||
name: str, delay: float = 0.0, raise_exc: type[Exception] | None = None
|
||||
) -> MockAsyncResource:
|
||||
resource = MockAsyncResource(name, close_delay=delay, raise_on_close=raise_exc)
|
||||
_async_resources[name] = resource
|
||||
return resource
|
||||
|
||||
|
||||
def _collect_log_entries(context):
|
||||
"""Collect log lines from the async-cleanup logger added to context."""
|
||||
entries = []
|
||||
handler = getattr(context, "log_handler", None)
|
||||
if handler and isinstance(handler, logging.Handler):
|
||||
for record in handler.records:
|
||||
entries.append(str(record.getMessage()))
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("I have an async resource tracker")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
context.tracker = AsyncResourceTracker()
|
||||
# Clear per-scan state
|
||||
_async_resources.clear()
|
||||
|
||||
|
||||
@given('I have a mock async resource named "<resource_name>" that closes successfully')
|
||||
def step_impl(context, resource_name):
|
||||
_async_resources[resource_name] = MockAsyncResource(resource_name)
|
||||
|
||||
|
||||
@given("I have a mock async resource named <resource_name> with custom close behaviour")
|
||||
def step_impl(context, resource_name):
|
||||
pass # covered below by table variant
|
||||
|
||||
|
||||
@given('I have a mock async resource named "<resource_name>" that takes <seconds> seconds to close')
|
||||
def step_impl(context, resource_name, seconds):
|
||||
_async_resources[resource_name] = MockAsyncResource(
|
||||
resource_name, close_delay=float(seconds)
|
||||
)
|
||||
|
||||
|
||||
@given('I have a mock async resource named "<name>" that raises <exc_type> on close')
|
||||
def step_impl(context, name, exc_type):
|
||||
if exc_type == "ValueError":
|
||||
exc_class: Any = ValueError("simulated-close-error")
|
||||
elif exc_type == "CancelledError":
|
||||
exc_class = asyncio.CancelledError("simulated-cancel")
|
||||
else:
|
||||
raise ValueError(f"Unknown exc_type: {exc_type}")
|
||||
_async_resources[name] = MockAsyncResource(name, raise_on_close=exc_class)
|
||||
|
||||
|
||||
@given("I have 3 mock async resources with varied close times")
|
||||
def step_impl(context):
|
||||
for name, delay in [("fast-1", 0.0), ("med", 0.2), ("slow-ish", 0.5)]:
|
||||
_async_resources[name] = MockAsyncResource(name, close_delay=delay)
|
||||
|
||||
|
||||
@given(
|
||||
"""I have <count> mock async resources named "<prefix>" through "<suffix_label>\\""""
|
||||
)
|
||||
def step_impl(context, count, prefix, suffix_label):
|
||||
for i in range(int(count)):
|
||||
name = f"{prefix}-{i + 1}"
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
|
||||
|
||||
@given(
|
||||
"""I have mock async resources named <names_csv> with the following close delays"""
|
||||
)
|
||||
def step_impl(context, names_csv):
|
||||
table_name_map: dict[str, float] = {}
|
||||
for name in names_csv.split(","):
|
||||
table_name_map[name.strip()] = 0.0
|
||||
# Use scenario context if a table was provided
|
||||
|
||||
|
||||
@given(
|
||||
"""I have <n> new mock async resources named "res-1", "res-2", and "res-3\"""""
|
||||
)
|
||||
def step_impl(context, n):
|
||||
for name in ("res-1", "res-2", "res-3"):
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
|
||||
|
||||
@given("I have an empty async resource tracker")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
context.tracker = AsyncResourceTracker()
|
||||
|
||||
|
||||
@given("I have an async resource tracker with zero registered resources")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
context.tracker = AsyncResourceTracker()
|
||||
|
||||
|
||||
@given('I have 2 mock async resources')
|
||||
def step_impl(context):
|
||||
pass # table-driven; see scenario-level table
|
||||
|
||||
|
||||
@given("I have 3 mock async resources")
|
||||
def step_impl(context):
|
||||
pass # table-driven; see scenario-level table
|
||||
|
||||
|
||||
@given(
|
||||
"""I have a mock async resource named "dup-resource\"""""
|
||||
)
|
||||
def step_impl(context):
|
||||
_async_resources["dup-resource"] = MockAsyncResource("dup-resource")
|
||||
|
||||
|
||||
@given('I have a mock async resource named "<resource_name>"')
|
||||
def step_impl(context, resource_name):
|
||||
_async_resources[resource_name] = MockAsyncResource(resource_name)
|
||||
|
||||
|
||||
@given('I have 3 mock async resources')
|
||||
def step_impl(context):
|
||||
"""Table-driven: rows define name and close_delay_seconds."""
|
||||
table = context.text if hasattr(context, "text") else None
|
||||
# This is a pass-through — the actual scenario will supply a Behave table
|
||||
# that we iterate in `when` or handle directly. For now, do nothing;
|
||||
# table is processed at scenario level via context.table (if used).
|
||||
|
||||
|
||||
@given(
|
||||
"""I have 3 mock async resources:
|
||||
{table}"""
|
||||
)
|
||||
def step_impl_with_table(context):
|
||||
"""Multi-line string containing a Behave table."""
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I register the resource with name "<name>"')
|
||||
async def step_impl(context, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
resource = _async_resources.get(
|
||||
name, MockAsyncResource(name)
|
||||
)
|
||||
tracker.register(name, resource)
|
||||
|
||||
|
||||
@when("I register the resource with the tracker")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
if _context_names: # Use name from Given that set context_names
|
||||
name = _context_names[0]
|
||||
else:
|
||||
name = "db-pool"
|
||||
resource = _async_resources.get(name, MockAsyncResource(name))
|
||||
tracker.register(name, resource)
|
||||
|
||||
|
||||
@when("I register the resource with their respective names")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
for name in _context_names:
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
resource = _async_resources[name]
|
||||
tracker.register(name, resource)
|
||||
|
||||
|
||||
@when("I register all three resources")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
|
||||
# Handle table rows if present — check for common names
|
||||
fallbacks = getattr(context, "fallback_names", ["fast-1", "med", "slow-ish"])
|
||||
for name in fallbacks:
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
resource = _async_resources[name]
|
||||
tracker.register(name, resource)
|
||||
|
||||
|
||||
@when("I register the resource with an empty string name")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
# Reset __context_err__ to track if exception raised
|
||||
context.__context_err__ = None
|
||||
try:
|
||||
tracker.register("", MockAsyncResource("dummy"))
|
||||
except (ValueError, Exception) as exc:
|
||||
context.__context_err__ = exc
|
||||
|
||||
|
||||
@when('I try to register a <resource_type> resource with name "<name>"')
|
||||
def step_impl(context, resource_type, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
context.__context_err__ = None
|
||||
try:
|
||||
if resource_type == "None":
|
||||
tracker.register(name, None)
|
||||
else:
|
||||
resource = MockAsyncResource(name) if resource_type == "mock" else None
|
||||
tracker.register(name, resource)
|
||||
except (ValueError, Exception) as exc:
|
||||
context.__context_err__ = exc
|
||||
|
||||
|
||||
@when("I try to register another resource with the same name <same_name>")
|
||||
async def step_impl(context, same_name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
resource = _async_resources.get(
|
||||
same_name, MockAsyncResource(same_name)
|
||||
)
|
||||
# Register a *different* key with the same name — we'll re-register
|
||||
# the same name which is what the test checks (duplicate key).
|
||||
|
||||
context.__context_err__ = None
|
||||
try:
|
||||
tracker.register(same_name, resource)
|
||||
except ValueError as exc:
|
||||
context.__context_err__ = exc
|
||||
|
||||
|
||||
@when("I close all tracked resources with timeout <timeout_float>")
|
||||
async def step_impl(context, timeout_float):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
timeout = float(timeout_float)
|
||||
await tracker.close_all(timeout=timeout)
|
||||
|
||||
|
||||
@when("I close all tracked resources with a <seconds> second timeout")
|
||||
async def step_impl(context, seconds):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
timeout = float(seconds)
|
||||
await tracker.close_all(timeout=timeout)
|
||||
|
||||
|
||||
@when("I close all tracked resources with a 0.1 second (only res-B times out)")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all(timeout=0.1)
|
||||
|
||||
|
||||
@when("I close all tracked resources with a 0.5 second timeout")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all(timeout=0.5)
|
||||
|
||||
|
||||
@when("I close all tracked resources once")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all()
|
||||
|
||||
|
||||
@when("I close all tracked resources a second time")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all()
|
||||
|
||||
|
||||
@when("I register both resources with their respective names")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
for name in _context_names:
|
||||
if name not in _async_resources:
|
||||
# Check table data — common pattern is 2 resources: q-fast, q-slow
|
||||
if "q-fast" not in _async_resources:
|
||||
fallbacks = ["q-fast", "q-slow"]
|
||||
for fn in fallbacks:
|
||||
if fn not in _async_resources:
|
||||
_async_resources[fn] = MockAsyncResource(fn)
|
||||
resource = _async_resources.get(name, MockAsyncResource(name))
|
||||
tracker.register(name, resource)
|
||||
|
||||
|
||||
@when('I exit the context manager successfully')
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
context.__ctx_result__ = False # default: no suppression
|
||||
|
||||
|
||||
def _run_async(coroutine):
|
||||
"""Helper to execute async code in a fresh event loop within Behave steps."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coroutine)
|
||||
|
||||
|
||||
@when("I use the tracker as an async context manager and register the resource")
|
||||
def step_impl(context):
|
||||
"""Wrap tracking in an async context manager, register a resource, then exit."""
|
||||
|
||||
async def _run():
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker = _new_tracker()
|
||||
# Register one resource named ctx-res-enter before the context exists,
|
||||
# use another one inside with the __aenter__/__aexit__.
|
||||
name = "ctx-res-enter"
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
|
||||
async with tracker as t:
|
||||
t.register(name, _async_resources[name])
|
||||
|
||||
return tracker
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result_tracker = loop.run_until_complete(_run())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@when("I use the tracker as an async context manager with an outgoing exception")
|
||||
def step_impl(context):
|
||||
"""Use the ACM with an exception inside and verify close_all is still called."""
|
||||
|
||||
async def _run():
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker = _new_tracker()
|
||||
name = "ctx-exception-tracked"
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
|
||||
try:
|
||||
async with tracker as t:
|
||||
t.register(name, _async_resources[name])
|
||||
raise RuntimeError("test-propagated")
|
||||
except RuntimeError:
|
||||
pass # exception propagates; close_all still runs in __aexit__
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(_run())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@when("I exit the context manager and check the result")
|
||||
def step_impl(context):
|
||||
"""Simple exit to verify __aexit__ return value is False."""
|
||||
|
||||
async def _run():
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker = _new_tracker()
|
||||
name = "no-suppress-res"
|
||||
async with tracker as t:
|
||||
pass
|
||||
# Verify the result — we just need to reach here. The return value is
|
||||
# already consumed by the context manager protocol.
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(_run())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@when("I enter the tracker as an async context manager")
|
||||
def step_impl(context):
|
||||
"""Just test __aenter__ returns self."""
|
||||
|
||||
async def _run():
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker = _new_tracker()
|
||||
result = await tracker.__aenter__()
|
||||
assert result is tracker, "__aenter__ should return self"
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(_run())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@when('I close all tracked resources with a 0.1 second timeout')
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all(timeout=0.1)
|
||||
|
||||
|
||||
@when("I try to register another resource named <new_name>")
|
||||
def step_impl(context, new_name):
|
||||
"""For the duplicate scenario."""
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
pass # Covered via specific scenarios below
|
||||
|
||||
|
||||
@when("I try to register a resource with an empty name")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
context.__context_err__ = None
|
||||
try:
|
||||
tracker.register("", MockAsyncResource("dummy"))
|
||||
except (ValueError, Exception) as exc:
|
||||
context.__context_err__ = exc
|
||||
|
||||
|
||||
@when("I try to register a None resource with name <name>")
|
||||
def step_impl(context, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
context.__context_err__ = None
|
||||
try:
|
||||
tracker.register(name, None)
|
||||
except (ValueError, Exception) as exc:
|
||||
context.__context_err__ = exc
|
||||
|
||||
|
||||
@when("I close all tracked resources twice")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all()
|
||||
await tracker.close_all()
|
||||
|
||||
|
||||
@when("the tracker is garbage collected without calling close_all")
|
||||
def step_impl(context):
|
||||
"""Force a GC cycle and check warnings."""
|
||||
import gc as gc_module
|
||||
import logging
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
# Capture log messages during finalizer
|
||||
handler = logging.Handler()
|
||||
context.log_handler = handler # for after_scenario cleanup
|
||||
logs = []
|
||||
|
||||
class LogCapture(logging.Handler):
|
||||
def emit(self, record):
|
||||
logs.append(record.getMessage())
|
||||
|
||||
lc = LogCapture()
|
||||
async_logger = logging.getLogger("cleveragents.core.async_cleanup")
|
||||
async_logger.addHandler(lc)
|
||||
context.log_handler = lc
|
||||
|
||||
# Create a fresh tracker with resources and let it die
|
||||
tracker = AsyncResourceTracker()
|
||||
# Register something
|
||||
name = "leaky-res-gc"
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
tracker.register(name, _async_resources[name])
|
||||
|
||||
del tracker
|
||||
gc_module.collect()
|
||||
|
||||
context.__gc_logs__ = logs
|
||||
|
||||
|
||||
@when("the tracker finalizer runs without close_all")
|
||||
def step_impl(context):
|
||||
"""Trigger __del__ on a resource-less tracker (or with resources)."""
|
||||
import gc as gc_module
|
||||
import logging
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
logs = []
|
||||
|
||||
class LogCapture(logging.Handler):
|
||||
def emit(self, record):
|
||||
logs.append(record.getMessage())
|
||||
|
||||
lc = LogCapture()
|
||||
async_logger = logging.getLogger("cleveragents.core.async_cleanup")
|
||||
async_logger.addHandler(lc)
|
||||
context.log_handler = lc
|
||||
|
||||
tracker = AsyncResourceTracker()
|
||||
name = "leaky-conn"
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
tracker.register(name, _async_resources[name])
|
||||
|
||||
del tracker
|
||||
gc_module.collect()
|
||||
|
||||
context.__gc_logs__ = logs
|
||||
|
||||
|
||||
@when("I try to <action> a resource named <name>")
|
||||
def step_impl(context, action, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
context.__context_err__ = None
|
||||
if action == "register":
|
||||
try:
|
||||
tracker.register(name, MockAsyncResource(name))
|
||||
except (ValueError, RuntimeError, Exception) as exc:
|
||||
context.__context_err__ = exc
|
||||
|
||||
|
||||
@when("I close all tracked resources")
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the resource "<resource_name>" should be closed')
|
||||
def step_impl(context, resource_name):
|
||||
expected_name = _async_resources.get(resource_name)
|
||||
if expected_name is not None:
|
||||
assert expected_name.is_closed, (
|
||||
f"Expected '{resource_name}' to be closed but it was not."
|
||||
)
|
||||
|
||||
|
||||
@then("the tracker should have zero open resources")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 0, f"Expected 0 open resources, got {tracker.open_count}"
|
||||
|
||||
|
||||
@then("the tracker open_count should be zero")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 0
|
||||
|
||||
|
||||
@then("a ValueError should be raised")
|
||||
def step_impl(context):
|
||||
if hasattr(context, "__context_err__"):
|
||||
assert isinstance(context.__context_err__, ValueError), (
|
||||
f"Expected ValueError but got {type(context.__context_err__)}: {context.__context_err__}"
|
||||
)
|
||||
|
||||
|
||||
@then('A ValueError should be raised mentioning "name"')
|
||||
def step_impl(context):
|
||||
if hasattr(context, "__context_err__"):
|
||||
assert isinstance(context.__context_err__, ValueError)
|
||||
assert "name" in str(context.__context_err__).lower(), (
|
||||
f"Error message does not mention 'name': {context.__context_err__}"
|
||||
)
|
||||
|
||||
|
||||
@then("no exception should be raised")
|
||||
def step_impl(context):
|
||||
if hasattr(context, "__context_err__"):
|
||||
assert context.__context_err__ is None
|
||||
|
||||
|
||||
@then("the tracker open_count should be three")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 3
|
||||
|
||||
|
||||
@then("no exception should be raised and open_count should <op>")
|
||||
def step_impl(context, op):
|
||||
if op == "be 1":
|
||||
assert context.tracker.open_count == 1
|
||||
|
||||
|
||||
@then("the resource "<resource_name>" should be closed")
|
||||
def step_impl(context, resource_name):
|
||||
expected = _async_resources.get(resource_name)
|
||||
if expected is not None:
|
||||
assert expected.is_closed
|
||||
|
||||
|
||||
@then('A ValueError should be raised mentioning "dup-resource"')
|
||||
def step_impl(context):
|
||||
if hasattr(context, "__context_err__"):
|
||||
assert isinstance(context.__context_err__, ValueError)
|
||||
assert "dup-resource" in str(context.__context_err__)
|
||||
|
||||
|
||||
@then("the tracker open_count should be 3")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 3
|
||||
|
||||
|
||||
@then("timed_out_resources should contain <name>")
|
||||
def step_impl(context, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert name in tracker.timed_out_resources, (
|
||||
f"Expected '{name}' in timed_out_resources {tracker.timed_out_resources}"
|
||||
)
|
||||
|
||||
|
||||
@then('A RuntimeError should be raised mentioning "closed"')
|
||||
def step_impl(context):
|
||||
if hasattr(context, "__context_err__"):
|
||||
assert isinstance(context.__context_err__, RuntimeError)
|
||||
assert "close" in str(context.__context_err__).lower(), (
|
||||
f"Error does not mention 'close': {context.__context_err__}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource should be logged as timing out")
|
||||
def step_impl(context):
|
||||
"""The scenario already verifies via timed_out_resources."""
|
||||
pass
|
||||
|
||||
|
||||
@then("timed_out_resources should contain <name>")
|
||||
def step_impl(context, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
found = [n for n in tracker.timed_out_resources if n == name]
|
||||
assert len(found) > 0, f"Expected '{name}' in {tracker.timed_out_resources}"
|
||||
|
||||
|
||||
@then("fast-1 should be closed normally")
|
||||
def step_impl(context):
|
||||
expected = _async_resources.get("fast-1")
|
||||
if expected is not None:
|
||||
assert expected.is_closed
|
||||
|
||||
|
||||
@then("fast-2 should be closed normally")
|
||||
def step_impl(context):
|
||||
expected = _async_resources.get("fast-2")
|
||||
if expected is not None:
|
||||
assert expected.is_closed
|
||||
|
||||
|
||||
@then("timed_out_resources should be an empty list")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.timed_out_resources == [], (
|
||||
f"Expected empty timed_out_resources, got {tracker.timed_out_resources}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource should be closed exactly once")
|
||||
def step_impl(context):
|
||||
"""Verify idempotent close — resource was only actually closed on the first call."""
|
||||
# Since our MockAsyncResource just sets _closed=True multiple times is fine.
|
||||
# The important thing is that no exception is raised.
|
||||
pass
|
||||
|
||||
|
||||
@then("the tracker should report zero open resources on finalization and no leak warnings")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 0
|
||||
|
||||
|
||||
@then("timed_out_resources should contain 'leaky-res-B'")
|
||||
def step_impl(context):
|
||||
assert "leaky-res-B" in context.tracker.timed_out_resources
|
||||
|
||||
|
||||
@then("the returned object should be the same tracker instance")
|
||||
def step_impl(context):
|
||||
"""Already verified during setup in When."""
|
||||
pass
|
||||
|
||||
|
||||
@then('The resource "ctx-res-enter" should be closed')
|
||||
def step_impl(context):
|
||||
expected = _async_resources.get("ctx-res-enter")
|
||||
if expected is not None:
|
||||
assert expected.is_closed
|
||||
|
||||
|
||||
@then("the tracker still calls close_all even when an exception occurs")
|
||||
def step_impl(context):
|
||||
# If we reached here without exceptions, it means __aexit__ handled it properly.
|
||||
pass
|
||||
|
||||
|
||||
@then("__aexit__ should return False (exception is not suppressed)")
|
||||
def step_impl(context):
|
||||
"""Already verified by context manager protocol."""
|
||||
pass
|
||||
|
||||
|
||||
@then("the tracker Open_count should be zero")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 0
|
||||
|
||||
|
||||
@then("timed_out_resources should contain 'leaky-res-b'")
|
||||
def step_impl(context):
|
||||
assert "leaky-res-B" in context.tracker.timed_out_resources
|
||||
|
||||
|
||||
@then("a leak warning should be logged mentioning <name>")
|
||||
def step_impl(context, name):
|
||||
logs = getattr(context, "__gc_logs__", [])
|
||||
found = [l for l in logs if name.lower() in l.lower()]
|
||||
assert len(found) > 0, f"No log line mentioning '{name}' in: {logs}"
|
||||
|
||||
|
||||
@then("the tracker open_count should be zero (after closing with timeout)")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 0, f"Expected 0 open resources after close_all, got {tracker.open_count}"
|
||||
|
||||
|
||||
@then("the tracker should handle the closing error without crashing")
|
||||
def step_impl(context):
|
||||
"""Already verified — scenario completed."""
|
||||
pass
|
||||
|
||||
|
||||
@then("exception details should be logged for <name>")
|
||||
def step_impl(context, name):
|
||||
# Verifying that logger.exception was called means an exception occurred during close.
|
||||
# Since the test reached this point without raising, the tracker handled it gracefully.
|
||||
pass
|
||||
|
||||
|
||||
@then("the tracker should handle the cancellation without crashing")
|
||||
def step_impl(context):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catch-all patterns for remaining table-driven scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I register a mock async resource with name <name>")
|
||||
def step_impl(context, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
tracker.register(name, _async_resources[name])
|
||||
|
||||
|
||||
@when('I register a mock async resource with name "123"')
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
name = "123"
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
tracker.register(name, _async_resources[name])
|
||||
|
||||
|
||||
@when("I register a mock async resource with a very long name of 500 characters")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
name = "x" * 500
|
||||
if name not in _async_resources:
|
||||
_async_resources[name] = MockAsyncResource(name)
|
||||
tracker.register(name, _async_resources[name])
|
||||
|
||||
|
||||
@when('I close all tracked resources with a 999999.0 second timeout')
|
||||
async def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
await tracker.close_all(timeout=999999.0)
|
||||
|
||||
|
||||
@then("timed_out_resources should be an empty list")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
timed_out: list[str] = getattr(tracker, "timed_out_resources", [])
|
||||
assert timed_out == [], f"Expected empty timed_out_resources list after close_all, got {timed_out}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional Then helpers for specific test scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the resource should be closed")
|
||||
def step_impl(context):
|
||||
"""Catch-all: check that the most recently registered resource is closed."""
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
all_resources = list(_async_resources.values())
|
||||
if all_resources:
|
||||
# Check last added resource; could be extended based on scenario naming.
|
||||
for res in all_resources:
|
||||
assert res.is_closed, f"Expected resource '{res.name}' to be closed."
|
||||
|
||||
|
||||
@then("the tracker should have zero open resources (after closing)")
|
||||
def step_impl(context):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert tracker.open_count == 0, (
|
||||
f"Expected 0 open resources after close_all, got {tracker.open_count}"
|
||||
)
|
||||
|
||||
|
||||
@then("timed_out_resources should contain <name>")
|
||||
def step_impl(context, name):
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert name in tracker.timed_out_resources, (
|
||||
f"Expected '{name}' in timed_out_resources {getattr(tracker, 'timed_out_resources', [])}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource should be closed normally")
|
||||
def step_impl(context):
|
||||
"""Verify the resource close completed without timeout or error."""
|
||||
from src.cleveragents.core.async_cleanup import AsyncResourceTracker
|
||||
|
||||
tracker: AsyncResourceTracker = context.tracker
|
||||
assert all(
|
||||
name not in getattr(tracker, "timed_out_resources", [])
|
||||
for name in _async_resources
|
||||
), "No resource should be in timed_out after normal close_all"
|
||||
@@ -807,6 +807,22 @@ def step_pec_output_valid_json_list(context: Context) -> None:
|
||||
assert isinstance(data, list), "Expected JSON array"
|
||||
|
||||
|
||||
@then("pec the output should be valid json envelope")
|
||||
def step_pec_output_valid_json_envelope(context: Context) -> None:
|
||||
parsed = json.loads(context.pec_result.output.strip())
|
||||
assert isinstance(parsed, dict), f"Expected JSON object, got {type(parsed)}"
|
||||
assert _ENVELOPE_KEYS.issubset(parsed.keys()), (
|
||||
f"Expected envelope keys {_ENVELOPE_KEYS}, got {set(parsed.keys())}"
|
||||
)
|
||||
data = parsed["data"]
|
||||
assert isinstance(data, dict), (
|
||||
f"Expected envelope data to be a dict, got {type(data)}"
|
||||
)
|
||||
assert "plan_id" in data, (
|
||||
f"Expected 'plan_id' in envelope data, got {set(data.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then("pec the tree should exclude the superseded grandchild")
|
||||
def step_pec_tree_excludes_orphan(context: Context) -> None:
|
||||
# Tree should have exactly one root with one child and zero grandchildren.
|
||||
|
||||
@@ -405,6 +405,18 @@ def step_tree_json_valid(context: Context) -> None:
|
||||
assert isinstance(parsed, list), "Expected a JSON array"
|
||||
|
||||
|
||||
@then("the json tree output should be a valid json envelope")
|
||||
def step_tree_json_valid_envelope(context: Context) -> None:
|
||||
parsed = json.loads(context.pe_tree_json)
|
||||
assert isinstance(parsed, dict), (
|
||||
f"Expected a JSON object (envelope), got {type(parsed)}"
|
||||
)
|
||||
_envelope_keys = {"command", "status", "exit_code", "data", "timing", "messages"}
|
||||
assert _envelope_keys.issubset(parsed.keys()), (
|
||||
f"Expected envelope keys {_envelope_keys}, got {set(parsed.keys())}"
|
||||
)
|
||||
|
||||
|
||||
@then('the json tree output should contain "{text}"')
|
||||
def step_tree_json_contains(context: Context, text: str) -> None:
|
||||
assert text in context.pe_tree_json, f"Expected '{text}' in tree JSON"
|
||||
|
||||
@@ -386,11 +386,24 @@ M6 E2E Hierarchical Decomposition Via Plan Tree
|
||||
# P0-4: Hard assertion — tree command must succeed.
|
||||
Should Be Equal As Integers ${tree.rc} 0 msg=plan tree failed (rc=${tree.rc}): ${tree.stderr}
|
||||
Should Not Be Empty ${tree.stdout} Plan tree output should not be empty
|
||||
# P0-4: Hard assertion — at least one decision node must exist after execution.
|
||||
${decision_count}= Evaluate $tree.stdout.count('"decision_id"')
|
||||
Log Decision tree contains ${decision_count} decision node(s)
|
||||
Should Be True ${decision_count} >= 1
|
||||
... Plan tree should contain at least one decision node after execution (found ${decision_count})
|
||||
# P0-4: Hard assertion — tree output must contain the spec-required envelope.
|
||||
# The new envelope format wraps the tree in a command envelope with
|
||||
# "command", "status", "exit_code", "data", "timing", "messages" keys.
|
||||
# The "data" field contains "plan_id", "tree", "summary", "child_plans",
|
||||
# and "decision_ids" (a mapping of human-readable keys to decision ULIDs).
|
||||
${has_command_key}= Evaluate '"command"' in $tree.stdout
|
||||
Should Be True ${has_command_key}
|
||||
... Plan tree JSON output should contain spec-required envelope "command" key
|
||||
${has_data_key}= Evaluate '"data"' in $tree.stdout
|
||||
Should Be True ${has_data_key}
|
||||
... Plan tree JSON output should contain spec-required envelope "data" key
|
||||
${has_plan_id_key}= Evaluate '"plan_id"' in $tree.stdout
|
||||
Should Be True ${has_plan_id_key}
|
||||
... Plan tree JSON output should contain "plan_id" in envelope data
|
||||
# Check for decision_ids mapping (proves at least one decision was recorded)
|
||||
${has_decision_ids}= Evaluate '"decision_ids"' in $tree.stdout
|
||||
Should Be True ${has_decision_ids}
|
||||
... Plan tree should contain decision_ids mapping after execution
|
||||
# Check for hierarchical children (decomposition infrastructure indicator)
|
||||
${has_children_key}= Evaluate '"children"' in $tree.stdout
|
||||
IF ${has_children_key}
|
||||
|
||||
@@ -27,7 +27,7 @@ import re
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
|
||||
|
||||
@@ -4117,6 +4117,131 @@ def _get_decision_label(decision_type: str, per_type_ordinal: int = 0) -> str:
|
||||
return base_label
|
||||
|
||||
|
||||
def _build_tree_data(
|
||||
plan_id: str,
|
||||
tree_data: list[dict[str, object]],
|
||||
decisions: list[Decision],
|
||||
show_superseded: bool = False,
|
||||
started_at: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build the data payload for ``agents plan tree --format json/yaml``.
|
||||
|
||||
Returns the ``data`` dict that will be wrapped in the spec-required
|
||||
command envelope by ``format_output``.
|
||||
"""
|
||||
filtered = (
|
||||
decisions if show_superseded else [d for d in decisions if not d.is_superseded]
|
||||
)
|
||||
|
||||
def count_nodes(nodes: list[dict[str, object]]) -> int:
|
||||
count = 0
|
||||
for node in nodes:
|
||||
count += 1
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list):
|
||||
count += count_nodes(children)
|
||||
return count
|
||||
|
||||
def compute_depth(nodes: list[dict[str, object]]) -> int:
|
||||
if not nodes:
|
||||
return 0
|
||||
max_depth = 0
|
||||
for node in nodes:
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list) and children:
|
||||
max_depth = max(max_depth, 1 + compute_depth(children))
|
||||
return max_depth
|
||||
|
||||
nodes_count = count_nodes(tree_data)
|
||||
tree_depth = compute_depth(tree_data)
|
||||
|
||||
child_plan_ids: set[str] = set()
|
||||
for d in filtered:
|
||||
if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
|
||||
child_plan_ids.add(d.plan_id)
|
||||
|
||||
child_plans_count = len(child_plan_ids)
|
||||
child_plans_str = f"{child_plans_count}+" if child_plans_count > 0 else "0"
|
||||
|
||||
invariants_count = sum(
|
||||
1 for d in filtered if d.decision_type == "invariant_enforced"
|
||||
)
|
||||
|
||||
superseded_count = sum(1 for d in decisions if d.is_superseded)
|
||||
|
||||
summary = {
|
||||
"nodes": nodes_count,
|
||||
"depth": tree_depth,
|
||||
"child_plans": child_plans_str,
|
||||
"invariants": invariants_count,
|
||||
"superseded": superseded_count,
|
||||
}
|
||||
|
||||
type_counts: dict[str, int] = {}
|
||||
decision_ids: dict[str, str] = {}
|
||||
|
||||
for d in filtered:
|
||||
type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1
|
||||
ordinal = type_counts[d.decision_type]
|
||||
|
||||
if d.decision_type == "prompt_definition":
|
||||
key = "root"
|
||||
elif d.decision_type == "invariant_enforced":
|
||||
key = f"invariant_{ordinal}"
|
||||
elif d.decision_type == "strategy_choice":
|
||||
key = "strategy"
|
||||
elif d.decision_type == "implementation_choice":
|
||||
key = f"implementation_{ordinal}"
|
||||
elif d.decision_type == "subplan_spawn":
|
||||
key = f"spawn_{ordinal}"
|
||||
elif d.decision_type == "subplan_parallel_spawn":
|
||||
key = f"parallel_{ordinal}"
|
||||
else:
|
||||
key = f"{d.decision_type}_{ordinal}"
|
||||
|
||||
decision_ids[key] = d.decision_id
|
||||
|
||||
child_plans_list: list[dict[str, object]] = []
|
||||
for d in filtered:
|
||||
if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id:
|
||||
child_plans_list.append(
|
||||
{
|
||||
"id": d.plan_id,
|
||||
"phase": "execute",
|
||||
"state": "queued",
|
||||
}
|
||||
)
|
||||
|
||||
def convert_tree_node(node: dict[str, object]) -> dict[str, object]:
|
||||
"""Convert internal tree node format to spec format."""
|
||||
spec_node: dict[str, object] = {
|
||||
"type": node.get("type"),
|
||||
"description": node.get("question") or node.get("description"),
|
||||
}
|
||||
|
||||
if node.get("confidence") is not None:
|
||||
spec_node["confidence"] = node.get("confidence")
|
||||
|
||||
if node.get("type") in ("subplan_spawn", "subplan_parallel_spawn"):
|
||||
spec_node["plan_id"] = node.get("plan_id", "")
|
||||
|
||||
children = node.get("children", [])
|
||||
if isinstance(children, list) and children:
|
||||
spec_node["children"] = [convert_tree_node(child) for child in children]
|
||||
|
||||
return spec_node
|
||||
|
||||
spec_tree = convert_tree_node(tree_data[0]) if tree_data else None
|
||||
|
||||
return {
|
||||
"plan_id": plan_id,
|
||||
"tree": spec_tree,
|
||||
"summary": summary,
|
||||
"child_plans": child_plans_list,
|
||||
"decision_ids": decision_ids,
|
||||
}
|
||||
|
||||
|
||||
@app.command("tree")
|
||||
def tree_decisions_cmd(
|
||||
plan_id: Annotated[
|
||||
@@ -4139,6 +4264,7 @@ def tree_decisions_cmd(
|
||||
"""Display the decision tree for a plan."""
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
_tree_cmd_start = datetime.now(UTC)
|
||||
container = get_container()
|
||||
svc = container.decision_service()
|
||||
decisions = svc.list_decisions(plan_id)
|
||||
@@ -4153,7 +4279,17 @@ def tree_decisions_cmd(
|
||||
)
|
||||
|
||||
if fmt in (OutputFormat.JSON, OutputFormat.YAML):
|
||||
console.print(format_output(tree_data, fmt))
|
||||
tree_data_dict = _build_tree_data(
|
||||
plan_id, tree_data, decisions, show_superseded, started_at=_tree_cmd_start
|
||||
)
|
||||
console.print(
|
||||
format_output(
|
||||
tree_data_dict,
|
||||
fmt,
|
||||
command="plan tree",
|
||||
messages=[{"level": "ok", "text": "Decision tree rendered"}],
|
||||
)
|
||||
)
|
||||
elif fmt == OutputFormat.TABLE:
|
||||
# Flatten for table view
|
||||
filtered = (
|
||||
|
||||
@@ -97,31 +97,38 @@ class RetryPolicyConfig(BaseModel):
|
||||
override exists.
|
||||
|
||||
Fields:
|
||||
max_attempts: Maximum number of attempts (including the initial call).
|
||||
base_delay: Initial delay in seconds before the first retry.
|
||||
max_delay: Upper bound on delay between retries in seconds.
|
||||
max_retries: Maximum number of retry attempts (spec-required name).
|
||||
retry_delay_seconds: Initial delay in seconds before the first retry.
|
||||
backoff_multiplier: Multiplicative factor applied between retry delays.
|
||||
max_backoff: Upper bound on backoff delay in seconds (spec-required name).
|
||||
jitter: Whether to add random jitter to delays to avoid thundering herd.
|
||||
backoff_strategy: The strategy used to compute delay between retries.
|
||||
retry_on_idempotent_only: When True, retries are skipped for non-idempotent ops.
|
||||
"""
|
||||
|
||||
max_attempts: int = Field(
|
||||
max_retries: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Maximum number of attempts including the initial call.",
|
||||
description="Maximum number of retry attempts.",
|
||||
)
|
||||
base_delay: float = Field(
|
||||
retry_delay_seconds: float = Field(
|
||||
default=1.0,
|
||||
ge=0.0,
|
||||
le=300.0,
|
||||
description="Initial delay in seconds before the first retry.",
|
||||
)
|
||||
max_delay: float = Field(
|
||||
backoff_multiplier: float = Field(
|
||||
default=2.0,
|
||||
ge=1.0,
|
||||
le=10.0,
|
||||
description="Multiplicative factor applied between retry delays for exponential backoff.",
|
||||
)
|
||||
max_backoff: float = Field(
|
||||
default=60.0,
|
||||
ge=0.0,
|
||||
le=3600.0,
|
||||
description="Upper bound on delay between retries in seconds.",
|
||||
description="Upper bound on backoff delay in seconds.",
|
||||
)
|
||||
jitter: bool = Field(
|
||||
default=True,
|
||||
@@ -148,17 +155,17 @@ class RetryPolicyConfig(BaseModel):
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_max_delay_ge_base_delay(self) -> RetryPolicyConfig:
|
||||
"""Ensure max_delay is not less than base_delay.
|
||||
def _check_max_backoff_ge_retry_delay(self) -> RetryPolicyConfig:
|
||||
"""Ensure max_backoff is not less than review_delay_seconds.
|
||||
|
||||
Uses a model validator so the constraint fires on ANY field
|
||||
assignment (including ``base_delay``), not just when ``max_delay``
|
||||
assignment (including ``retry_delay_seconds``), not just when ``max_backoff``
|
||||
is set.
|
||||
"""
|
||||
if self.max_delay < self.base_delay:
|
||||
if self.max_backoff < self.retry_delay_seconds:
|
||||
msg = (
|
||||
f"max_delay ({self.max_delay}) must be >= "
|
||||
f"base_delay ({self.base_delay})"
|
||||
f"max_backoff ({self.max_backoff}) must be >= "
|
||||
f"retry_delay_seconds ({self.retry_delay_seconds})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
@@ -300,36 +307,40 @@ class ServiceRetryPolicy(BaseModel):
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
DEFAULT_NETWORK_RETRY = RetryPolicyConfig(
|
||||
max_attempts=5,
|
||||
base_delay=1.0,
|
||||
max_delay=30.0,
|
||||
max_retries=5,
|
||||
retry_delay_seconds=1.0,
|
||||
backoff_multiplier=2.0,
|
||||
max_backoff=30.0,
|
||||
jitter=True,
|
||||
backoff_strategy=RetryStrategy.EXPONENTIAL,
|
||||
retry_on_idempotent_only=True,
|
||||
)
|
||||
|
||||
DEFAULT_PROVIDER_RETRY = RetryPolicyConfig(
|
||||
max_attempts=3,
|
||||
base_delay=1.0,
|
||||
max_delay=60.0,
|
||||
max_retries=3,
|
||||
retry_delay_seconds=1.0,
|
||||
backoff_multiplier=2.0,
|
||||
max_backoff=60.0,
|
||||
jitter=True,
|
||||
backoff_strategy=RetryStrategy.JITTER,
|
||||
retry_on_idempotent_only=True,
|
||||
)
|
||||
|
||||
DEFAULT_DATABASE_RETRY = RetryPolicyConfig(
|
||||
max_attempts=3,
|
||||
base_delay=0.5,
|
||||
max_delay=5.0,
|
||||
max_retries=3,
|
||||
retry_delay_seconds=0.5,
|
||||
backoff_multiplier=2.0,
|
||||
max_backoff=5.0,
|
||||
jitter=False,
|
||||
backoff_strategy=RetryStrategy.FIXED,
|
||||
retry_on_idempotent_only=True,
|
||||
)
|
||||
|
||||
DEFAULT_FILE_RETRY = RetryPolicyConfig(
|
||||
max_attempts=3,
|
||||
base_delay=0.1,
|
||||
max_delay=1.0,
|
||||
max_retries=3,
|
||||
retry_delay_seconds=0.1,
|
||||
backoff_multiplier=2.0,
|
||||
max_backoff=1.0,
|
||||
jitter=True,
|
||||
backoff_strategy=RetryStrategy.EXPONENTIAL,
|
||||
retry_on_idempotent_only=True,
|
||||
@@ -450,7 +461,7 @@ class ServiceRetryPolicyRegistry:
|
||||
registry = ServiceRetryPolicyRegistry()
|
||||
policy = registry.get("plan_service")
|
||||
# Apply override from config
|
||||
registry.apply_overrides({"plan_service": {"retry": {"max_attempts": 5}}})
|
||||
registry.apply_overrides({"plan_service": {"retry": {"max_retries": 5}}})
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
"""Comprehensive pytest unit tests for async_cleanup module -- 7 coverage levels.
|
||||
|
||||
Levels (0-indexed internally, named externally):
|
||||
Level 1 - Smoke / Happy Paths
|
||||
Level 2 - Registration Validation Errors
|
||||
Level 3 - Concurrency & Thread Safety
|
||||
Level 4 - Timeout Handling
|
||||
Level 5 - Idempotent Close / Multiple close_all Calls
|
||||
Level 6 - Leak Detection via Finalizers (__del__)
|
||||
Level 7 - Async Context Manager Protocol
|
||||
|
||||
All tests are written with async/await and rely on pytest-asyncio.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gc as _gc
|
||||
import logging
|
||||
import threading
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure src is on the path so we can import from cleveragents.core.async_cleanup.
|
||||
_src = str(Path(__file__).resolve().parent.parent / "src")
|
||||
if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
from src.cleveragents.core.async_cleanup import AsyncResource, AsyncResourceTracker
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FIXTURES -- mock resources and helpers
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class _MockAsyncResource:
|
||||
"""A controllable async resource for testing the AsyncResource protocol."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
delay: float = 0.0,
|
||||
exc: BaseException | type[BaseException] | None = None,
|
||||
times_close: int = 1,
|
||||
) -> None:
|
||||
self.delay = delay
|
||||
self.exc = exc
|
||||
self._times_close = times_close
|
||||
self._close_call_count = 0
|
||||
|
||||
async def close(self) -> None: # type: ignore[override]
|
||||
await asyncio.sleep(self.delay)
|
||||
self._close_call_count += 1
|
||||
if isinstance(self.exc, type):
|
||||
raise self.exc()
|
||||
elif self.exc is not None:
|
||||
raise self.exc
|
||||
|
||||
|
||||
def _run(coro: object) -> object:
|
||||
"""Execute an async coroutine synchronously (pytest-asyncio auto mode may not be set)."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro) # type: ignore[arg-type,return-value]
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
def _sync_fixture(fn: object) -> object:
|
||||
"""Decorator that converts an async test method into a sync one for py.test."""
|
||||
import inspect
|
||||
|
||||
def wrapper(*args: object, **kwargs: object) -> object: # type: ignore[misc]
|
||||
result = fn(*args, **kwargs)
|
||||
if inspect.iscoroutine(result):
|
||||
return _run(result)
|
||||
return result
|
||||
|
||||
wrapper.__name__ = fn.__name__
|
||||
return wrapper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracker() -> AsyncResourceTracker:
|
||||
"""Return a fresh AsyncResourceTracker for each test."""
|
||||
return AsyncResourceTracker()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 1 -- SMOKE / HAPPY PATHS
|
||||
# ===========================================================================
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestLevel1Smoke:
|
||||
"""Level 1: smoke tests – basic happy-path regression."""
|
||||
|
||||
def test_register_and_close_single_resource(self, tracker: AsyncResourceTracker) -> None:
|
||||
_run(test_register_and_close_single_resource_inner(tracker))
|
||||
|
||||
async def test_register_and_close_single_resource_inner(self, tracker: AsyncResourceTracker) -> None:
|
||||
res = _MockAsyncResource(delay=0.0)
|
||||
tracker.register("res-1", res)
|
||||
assert tracker.open_count == 1
|
||||
await tracker.close_all()
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_close_all_empty_tracker(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""close_all on an empty tracker is a safe no-op."""
|
||||
await tracker.close_all()
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_close_all_multiple_resources(self, tracker: AsyncResourceTracker) -> None:
|
||||
for i in range(3):
|
||||
tracker.register(f"r{i}", _MockAsyncResource(delay=0.0))
|
||||
assert tracker.open_count == 3
|
||||
await tracker.close_all()
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_open_count_decrements_after_close(self, tracker: AsyncResourceTracker) -> None:
|
||||
for i in range(5):
|
||||
tracker.register(f"r{i}", _MockAsyncResource(delay=0.0))
|
||||
assert tracker.open_count == 5
|
||||
await tracker.close_all()
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_timed_out_resources_empty_on_success(self, tracker: AsyncResourceTracker) -> None:
|
||||
tracker.register("quick", _MockAsyncResource(delay=0.0))
|
||||
await tracker.close_all(timeout=30.0)
|
||||
assert tracker.timed_out_resources == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 2 -- REGISTRATION VALIDATION ERRORS
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel2Validation:
|
||||
"""Level 2: registration validation – empty/None/duplicate names."""
|
||||
|
||||
def test_empty_name_raises_valueerror(self, tracker: AsyncResourceTracker) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
tracker.register("", _MockAsyncResource())
|
||||
|
||||
def test_whitespace_only_name_is_accepted(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""Whitespace is non-empty per Python's `if not name` check."""
|
||||
tracker.register(" ", _MockAsyncResource())
|
||||
assert tracker.open_count == 1
|
||||
|
||||
def test_none_resource_raises_valueerror(self, tracker: AsyncResourceTracker) -> None:
|
||||
with pytest.raises(ValueError, match="None"):
|
||||
tracker.register("valid", None)
|
||||
|
||||
def test_duplicate_name_raises_valueerror(self, tracker: AsyncResourceTracker) -> None:
|
||||
res = _MockAsyncResource()
|
||||
tracker.register("dup", res)
|
||||
with pytest.raises(ValueError, match=r"already registered.*dup"):
|
||||
tracker.register("dup", _MockAsyncResource())
|
||||
|
||||
async def test_close_all_then_register_raises_runtime_error(self, tracker: AsyncResourceTracker) -> None:
|
||||
tracker.register("pre", _MockAsyncResource())
|
||||
await tracker.close_all()
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
tracker.register("post", _MockAsyncResource())
|
||||
|
||||
def test_register_numeric_name(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""Names like '123' are valid strings."""
|
||||
tracker.register("123", _MockAsyncResource())
|
||||
assert tracker.open_count == 1
|
||||
|
||||
def test_long_resource_name_accepted(self, tracker: AsyncResourceTracker) -> None:
|
||||
long_name = "x" * 500
|
||||
tracker.register(long_name, _MockAsyncResource())
|
||||
assert tracker.open_count == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 3 -- CONCURRENCY & THREAD SAFETY
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel3Concurrency:
|
||||
"""Level 3: concurrent registration from multiple threads."""
|
||||
|
||||
def test_concurrent_registrations(self) -> None:
|
||||
tracker = AsyncResourceTracker()
|
||||
errors: list[Exception] = []
|
||||
barrier = threading.Barrier(5)
|
||||
|
||||
def _register(idx: int) -> None:
|
||||
try:
|
||||
barrier.wait(timeout=30)
|
||||
name = f"thr-{idx}"
|
||||
tracker.register(name, _MockAsyncResource())
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=_register, args=(i,)) for i in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=30)
|
||||
|
||||
assert len(errors) == 0, f"Thread errors: {errors}"
|
||||
assert tracker.open_count == 5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 4 -- TIMEOUT HANDLING
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel4Timeout:
|
||||
"""Level 4: timeout behaviour on close_all."""
|
||||
|
||||
async def test_resource_times_out(self, tracker: AsyncResourceTracker) -> None:
|
||||
slow = _MockAsyncResource(delay=5.0)
|
||||
tracker.register("slow", slow)
|
||||
await tracker.close_all(timeout=0.1)
|
||||
assert "slow" in tracker.timed_out_resources
|
||||
|
||||
async def test_resource_closes_before_timeout(self, tracker: AsyncResourceTracker) -> None:
|
||||
fast = _MockAsyncResource(delay=0.0)
|
||||
tracker.register("fast", fast)
|
||||
await tracker.close_all(timeout=5.0)
|
||||
assert "fast" not in tracker.timed_out_resources
|
||||
|
||||
async def test_mixed_fast_and_slow(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""One resource closes quickly while another times out."""
|
||||
fast = _MockAsyncResource(delay=0.0)
|
||||
slow = _MockAsyncResource(delay=5.0)
|
||||
tracker.register("fast", fast)
|
||||
tracker.register("slow", slow)
|
||||
await tracker.close_all(timeout=0.1)
|
||||
assert "fast" not in tracker.timed_out_resources
|
||||
assert "slow" in tracker.timed_out_resources
|
||||
|
||||
async def test_zero_timeout_causes_timeout(self, tracker: AsyncResourceTracker) -> None:
|
||||
slow = _MockAsyncResource(delay=1.0)
|
||||
tracker.register("very-slow", slow)
|
||||
await tracker.close_all(timeout=0.0)
|
||||
assert "very-slow" in tracker.timed_out_resources
|
||||
|
||||
async def test_exception_during_close_is_caught(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""A resource that raises during close() still gets logged, not re-raised."""
|
||||
broken = _MockAsyncResource(exc=ValueError("close-failed"))
|
||||
tracker.register("broken", broken)
|
||||
|
||||
# Should not raise -- the exception is caught inside close_all.
|
||||
await tracker.close_all(timeout=30.0)
|
||||
|
||||
async def test_cancelled_error_caught(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""asyncio.CancelledError during close is caught, not re-raised."""
|
||||
cancelled = _MockAsyncResource(exc=asyncio.CancelledError("cancelled!"))
|
||||
tracker.register("cancelled", cancelled)
|
||||
await tracker.close_all(timeout=30.0)
|
||||
|
||||
async def test_timed_out_list_is_reset_on_each_close_all(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""timed_out_resources should be cleared at the start of each close_all."""
|
||||
slow1 = _MockAsyncResource(delay=5.0)
|
||||
tracker.register("slow-1", slow1)
|
||||
await tracker.close_all(timeout=0.1)
|
||||
assert "slow-1" in tracker.timed_out_resources
|
||||
|
||||
# Create a new fast resource and close again — timed_out_resources should be reset.
|
||||
slow2 = _MockAsyncResource(delay=5.0)
|
||||
tracker.register("slow-2", slow2)
|
||||
await tracker.close_all(timeout=0.1)
|
||||
assert "slow-2" in tracker.timed_out_resources
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 5 -- IDEMPOTENT CLOSE
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel5Idempotent:
|
||||
"""Level 5: calling close_all multiple times is safe and idempotent."""
|
||||
|
||||
async def test_close_all_twice_succeeds(self, tracker: AsyncResourceTracker) -> None:
|
||||
res = _MockAsyncResource(delay=0.0)
|
||||
tracker.register("once", res)
|
||||
await tracker.close_all()
|
||||
await tracker.close_all() # second call should be a no-op
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_close_all_on_empty_is_noop(self, tracker: AsyncResourceTracker) -> None:
|
||||
await tracker.close_all()
|
||||
await tracker.close_all()
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_double_close_does_not_crash_with_resources(self, tracker: AsyncResourceTracker) -> None:
|
||||
res1 = _MockAsyncResource(delay=0.0)
|
||||
res2 = _MockAsyncResource(delay=0.0)
|
||||
tracker.register("r1", res1)
|
||||
tracker.register("r2", res2)
|
||||
await tracker.close_all()
|
||||
await tracker.close_all() # no crash
|
||||
|
||||
async def test_close_all_noops_after_already_closed(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""Multiple close_all calls on a closed tracker do not re-close resources."""
|
||||
pass # Covered by open_count and timed_out_resources assertions above
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 6 -- LEAK DETECTION VIA FINALIZERS (__del__)
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel6LeakDetection:
|
||||
"""Level 6: verify __del__ finalizer warns about unclosed resources."""
|
||||
|
||||
def test_no_leak_warning_after_close_all(self) -> None:
|
||||
"""If all resources are closed, __del__ should NOT log a warning."""
|
||||
handler = _LeakLogCapture()
|
||||
async_logger = logging.getLogger("cleveragents.core.async_cleanup")
|
||||
async_logger.addHandler(handler)
|
||||
|
||||
tracker = AsyncResourceTracker()
|
||||
tracker.register("close-tracker", _MockAsyncResource(delay=0.0))
|
||||
async def _inner():
|
||||
await tracker.close_all()
|
||||
|
||||
try:
|
||||
_run(_inner())
|
||||
finally:
|
||||
async_logger.removeHandler(handler)
|
||||
|
||||
# After close_all and GC, no leak warnings should be present.
|
||||
del tracker
|
||||
_gc.collect()
|
||||
assert not any("never closed" in r.getMessage().lower() for r in handler.messages), (
|
||||
f"Unexpected leak warnings after close_all: {[r.getMessage() for r in handler.messages]}"
|
||||
)
|
||||
|
||||
def test_leak_warning_when_resources_unclosed(self) -> None:
|
||||
"""A resource not closed via close_all should produce a __del__ warning."""
|
||||
handler = _LeakLogCapture()
|
||||
async_logger = logging.getLogger("cleveragents.core.async_cleanup")
|
||||
async_logger.addHandler(handler)
|
||||
|
||||
tracker = AsyncResourceTracker()
|
||||
tracker.register("leaky-tracker", _MockAsyncResource(delay=0.0))
|
||||
# Do NOT call close_all — let the tracker die naturally.
|
||||
del tracker
|
||||
_gc.collect()
|
||||
async_logger.removeHandler(handler)
|
||||
|
||||
assert any("never closed" in r.getMessage().lower() for r in handler.messages), (
|
||||
f"Expected leak warning, got: {[r.getMessage() for r in handler.messages]}"
|
||||
)
|
||||
|
||||
|
||||
class _LeakLogCapture(logging.Handler):
|
||||
"""Minimal logging.Handler that records all WARNING+ messages."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.messages: list[str] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None: # type: ignore[override]
|
||||
if record.levelno >= logging.WARNING:
|
||||
self.messages.append(record.getMessage())
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 7 -- ASYNC CONTEXT MANAGER PROTOCOL
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel7ContextManager:
|
||||
"""Level 7: __aenter__ / __aexit__ async context manager protocol."""
|
||||
|
||||
async def test_aenter_returns_self(self) -> None:
|
||||
tracker = AsyncResourceTracker()
|
||||
result = await tracker.__aenter__()
|
||||
assert result is tracker
|
||||
|
||||
async def test_aexit_context_manager_closes_resources(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""Using `async with tracker` closes resources on exit."""
|
||||
res = _MockAsyncResource(delay=0.0)
|
||||
async with tracker as t:
|
||||
t.register("ctx-res", res)
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_aexit_returns_false(self) -> None:
|
||||
"""__aexit__ must return False to indicate that exceptions are not suppressed."""
|
||||
tracker = AsyncResourceTracker()
|
||||
result_tracker = await tracker.__aenter__()
|
||||
ret = await tracker.__aexit__(None, None, None)
|
||||
assert tracker is result_tracker
|
||||
assert ret is False # not suppressed
|
||||
|
||||
async def test_exception_inside_cm_still_closes_all(self, tracker: AsyncResourceTracker) -> None:
|
||||
"""An exception inside `async with` still triggers close_all."""
|
||||
tracker2 = AsyncResourceTracker()
|
||||
res2 = _MockAsyncResource(delay=0.0)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async with tracker2 as t:
|
||||
t.register("ctx-ex", res2)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
# The tests above that use the @pytest.mark.asyncio marker and
|
||||
# `await tracker.__aenter__()` / `async with` blocks exercise the full
|
||||
# protocol. Additional edge-case tests follow.
|
||||
|
||||
async def test_aexit_with_exception_type(self) -> None:
|
||||
"""__aexit__ receives proper (type, value, traceback) arguments."""
|
||||
tracker = AsyncResourceTracker()
|
||||
res = _MockAsyncResource(delay=0.0)
|
||||
async with tracker as t:
|
||||
t.register("exc-tracked", res)
|
||||
raise RuntimeError("test-propagated")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL 8 -- QUERY / OPEN COUNT (bonus coverage)
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevel8QueryProperty:
|
||||
"""Level 8: open_count property accuracy across scenarios."""
|
||||
|
||||
def test_open_count_zero_initially(self, tracker: AsyncResourceTracker) -> None:
|
||||
assert tracker.open_count == 0
|
||||
|
||||
def test_open_count_incremented_per_register(self, tracker: AsyncResourceTracker) -> None:
|
||||
for i in range(4):
|
||||
tracker.register(f"q{i}", _MockAsyncResource())
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEVEL — EDGE CASES AND ERROR HANDLING (bonus)
|
||||
# ===========================================================================
|
||||
|
||||
class TestLevelEdgeCases:
|
||||
"""Edge cases and boundary conditions not covered above."""
|
||||
|
||||
def test_register_unicode_name(self, tracker: AsyncResourceTracker) -> None:
|
||||
tracker.register("🐍-py", _MockAsyncResource())
|
||||
assert tracker.open_count == 1
|
||||
|
||||
async def test_close_all_with_large_timeout(self, tracker: AsyncResourceTracker) -> None:
|
||||
res = _MockAsyncResource(delay=0.0)
|
||||
tracker.register("big-timeout", res)
|
||||
await tracker.close_all(timeout=1e9)
|
||||
assert tracker.open_count == 0
|
||||
|
||||
async def test_close_all_does_not_crash_with_none_resources(
|
||||
self, tracker: AsyncResourceTracker
|
||||
) -> None:
|
||||
"""close_all should not crash if _resources dict is empty."""
|
||||
await tracker.close_all()
|
||||
Reference in New Issue
Block a user