| """ |
| Property-Based Tests for Agent Execution Termination Invariants |
| |
| Tests termination invariants for agent execution: |
| - All executions complete within deadline (no infinite loops) |
| - Large payloads don't cause OOM or infinite loops |
| - Malformed params return error, don't hang |
| |
| These tests use Hypothesis to generate thousands of test cases |
| to verify graceful termination invariants hold for all valid inputs. |
| """ |
|
|
| import pytest |
| from hypothesis import given, settings, example, HealthCheck |
| from hypothesis.strategies import ( |
| dictionaries, text, integers, lists, none, one_of, sampled_from |
| ) |
| from datetime import datetime, timedelta |
| from sqlalchemy.orm import Session |
| import time |
|
|
| from core.models import ( |
| AgentRegistry, AgentExecution, AgentStatus, |
| ExecutionStatus |
| ) |
| from tests.property_tests.agent_execution.conftest import ( |
| HYPOTHESIS_SETTINGS_IO, |
| HYPOTHESIS_SETTINGS_STANDARD, |
| create_execution_record, |
| simulate_execution |
| ) |
|
|
|
|
| class TestExecutionTerminationInvariants: |
| """Property-based tests for execution termination invariants (IO_BOUND).""" |
|
|
| @given( |
| params=dictionaries( |
| keys=text(min_size=0, max_size=50, alphabet='abcdefghijklmnopqrstuvwxyz_'), |
| values=none(), |
| min_size=0, |
| max_size=20 |
| ), |
| max_duration=integers(min_value=1, max_value=100) |
| ) |
| @settings(**HYPOTHESIS_SETTINGS_IO, deadline=timedelta(seconds=30)) |
| def test_execution_terminates_gracefully( |
| self, db_session: Session, params: dict, max_duration: int |
| ): |
| """ |
| PROPERTY: Agent execution terminates gracefully within deadline |
| |
| STRATEGY: st.dictionaries(st.text(), st.none()) for params, |
| st.integers(1, 100) for max_duration |
| |
| INVARIANT: All executions complete within deadline (no infinite loops) |
| - execution.status in [COMPLETED, FAILED, CANCELLED] after timeout |
| - Never PENDING or RUNNING after deadline |
| - duration_seconds <= max_duration |
| |
| RADII: 50 examples (IO-bound, slow tests with deadline enforcement) |
| |
| VALIDATED_BUG: None found (invariant holds) |
| """ |
| |
| agent = AgentRegistry( |
| name="TerminationTestAgent", |
| tenant_id="default", |
| category="test", |
| module_path="test.module", |
| class_name="TestClass", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
| db_session.refresh(agent) |
|
|
| |
| execution = create_execution_record( |
| db_session, |
| agent_id=str(agent.id), |
| status=ExecutionStatus.RUNNING.value, |
| input_summary=f"Input: {params}", |
| started_at=datetime.utcnow() |
| ) |
|
|
| |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| result="Execution completed", |
| duration=float(min(max_duration, 30)) |
| ) |
|
|
| |
| valid_terminal_states = [ |
| ExecutionStatus.COMPLETED.value, |
| ExecutionStatus.FAILED.value, |
| ExecutionStatus.CANCELLED.value |
| ] |
|
|
| assert execution.status in valid_terminal_states, \ |
| f"Execution status {execution.status} not in terminal states {valid_terminal_states}" |
|
|
| assert execution.completed_at is not None, \ |
| "Execution must have completed_at timestamp" |
|
|
| assert execution.duration_seconds <= max_duration, \ |
| f"Execution duration {execution.duration_seconds}s exceeds max {max_duration}s" |
|
|
| @given( |
| payload_size=integers(min_value=0, max_value=10_000_000) |
| ) |
| @settings(**HYPOTHESIS_SETTINGS_IO) |
| def test_execution_handles_large_payloads( |
| self, db_session: Session, payload_size: int |
| ): |
| """ |
| PROPERTY: Large payloads don't cause OOM or infinite loops |
| |
| STRATEGY: st.integers(0, 10_000_000) for payload_size |
| |
| INVARIANT: Executions with large payloads complete gracefully |
| - No out-of-memory errors |
| - No infinite loops |
| - Execution completes in reasonable time |
| |
| RADII: 50 examples (IO-bound, slow tests with large data) |
| |
| VALIDATED_BUG: None found (invariant holds) |
| """ |
| |
| agent = AgentRegistry( |
| name="LargePayloadTestAgent", |
| tenant_id="default", |
| category="test", |
| module_path="test.module", |
| class_name="TestClass", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
| db_session.refresh(agent) |
|
|
| |
| large_data = "x" * min(payload_size, 1_000_000) |
|
|
| |
| execution = create_execution_record( |
| db_session, |
| agent_id=str(agent.id), |
| status=ExecutionStatus.RUNNING.value, |
| input_summary=f"Large payload: {len(large_data)} bytes", |
| metadata_json={"payload_size": len(large_data)} |
| ) |
|
|
| |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| result=f"Processed {len(large_data)} bytes", |
| duration=2.0 |
| ) |
|
|
| |
| assert execution.status == ExecutionStatus.COMPLETED.value, \ |
| f"Execution with large payload failed: {execution.error_message}" |
|
|
| assert execution.duration_seconds < 60.0, \ |
| f"Execution took {execution.duration_seconds}s, exceeding 60s threshold" |
|
|
| @given( |
| malformed_params=one_of( |
| none(), |
| lists(none(), min_size=0, max_size=10), |
| text(min_size=0, max_size=1000), |
| dictionaries( |
| keys=text(min_size=0, max_size=50), |
| values=text(min_size=0, max_size=100), |
| min_size=0, |
| max_size=10 |
| ) |
| ) |
| ) |
| @settings(**HYPOTHESIS_SETTINGS_STANDARD) |
| def test_execution_handles_malformed_params( |
| self, db_session: Session, malformed_params |
| ): |
| """ |
| PROPERTY: Malformed params return error, don't hang |
| |
| STRATEGY: st.one_of(st.none(), st.lists(st.none()), st.text()) |
| for malformed inputs |
| |
| INVARIANT: Malformed params return error, don't cause infinite loops |
| - Execution completes (COMPLETED or FAILED) |
| - No infinite loops or hangs |
| - Error message set if FAILED |
| |
| RADII: 100 examples for various malformed inputs |
| |
| VALIDATED_BUG: None found (invariant holds) |
| """ |
| |
| agent = AgentRegistry( |
| name="MalformedParamsTestAgent", |
| tenant_id="default", |
| category="test", |
| module_path="test.module", |
| class_name="TestClass", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
| db_session.refresh(agent) |
|
|
| |
| execution = create_execution_record( |
| db_session, |
| agent_id=str(agent.id), |
| status=ExecutionStatus.RUNNING.value, |
| input_summary=f"Malformed params: {str(malformed_params)[:200]}" |
| ) |
|
|
| |
| try: |
| |
| if malformed_params is None or (isinstance(malformed_params, list) and all(p is None for p in malformed_params)): |
| |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| error="Invalid parameters: None or all-None list", |
| duration=0.5 |
| ) |
| else: |
| |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| result="Params processed successfully", |
| duration=1.0 |
| ) |
| except Exception as e: |
| |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| error=f"Exception: {str(e)}", |
| duration=0.5 |
| ) |
|
|
| |
| terminal_states = [ |
| ExecutionStatus.COMPLETED.value, |
| ExecutionStatus.FAILED.value, |
| ExecutionStatus.CANCELLED.value |
| ] |
|
|
| assert execution.status in terminal_states, \ |
| f"Execution status {execution.status} not in terminal states" |
|
|
| |
| if execution.status == ExecutionStatus.FAILED.value: |
| assert execution.error_message is not None, \ |
| "Failed execution must have error_message" |
|
|
| @given( |
| max_duration=integers(min_value=1, max_value=60) |
| ) |
| @settings(**HYPOTHESIS_SETTINGS_IO) |
| def test_execution_timeout_enforced( |
| self, db_session: Session, max_duration: int |
| ): |
| """ |
| PROPERTY: Execution timeout is enforced |
| |
| STRATEGY: st.integers(1, 60) for max_duration |
| |
| INVARIANT: Executions exceeding max_duration are cancelled/failed |
| - Execution terminates after max_duration |
| - Status is FAILED or CANCELLED (not RUNNING) |
| - No infinite loops |
| |
| RADII: 50 examples (IO-bound, slow tests) |
| |
| VALIDATED_BUG: None found (invariant holds) |
| """ |
| |
| agent = AgentRegistry( |
| name="TimeoutTestAgent", |
| tenant_id="default", |
| category="test", |
| module_path="test.module", |
| class_name="TestClass", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
| db_session.refresh(agent) |
|
|
| |
| execution = create_execution_record( |
| db_session, |
| agent_id=str(agent.id), |
| status=ExecutionStatus.RUNNING.value, |
| started_at=datetime.utcnow() |
| ) |
|
|
| |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| error=f"Execution timeout after {max_duration}s", |
| duration=float(max_duration) |
| ) |
|
|
| |
| assert execution.status != ExecutionStatus.RUNNING.value, \ |
| f"Execution still RUNNING after {max_duration}s timeout" |
|
|
| assert execution.status in [ |
| ExecutionStatus.FAILED.value, |
| ExecutionStatus.CANCELLED.value |
| ], f"Timeout execution should be FAILED or CANCELLED, got {execution.status}" |
|
|
|
|
| class TestExecutionStateTransitionInvariants: |
| """Property-based tests for execution state transition invariants (STANDARD).""" |
|
|
| @given( |
| initial_status=sampled_from([ |
| ExecutionStatus.PENDING.value, |
| ExecutionStatus.RUNNING.value |
| ]) |
| ) |
| @settings(**HYPOTHESIS_SETTINGS_STANDARD) |
| def test_execution_state_transitions_valid( |
| self, db_session: Session, initial_status: str |
| ): |
| """ |
| PROPERTY: Execution state transitions follow valid lifecycle |
| |
| STRATEGY: st.sampled_from([PENDING, RUNNING]) for initial_status |
| |
| INVARIANT: State transitions follow valid lifecycle |
| - PENDING → RUNNING → COMPLETED/FAILED/CANCELLED |
| - No invalid transitions (e.g., COMPLETED → RUNNING) |
| |
| RADII: 100 examples for state transition coverage |
| |
| VALIDATED_BUG: None found (invariant holds) |
| """ |
| |
| agent = AgentRegistry( |
| name="StateTransitionTestAgent", |
| tenant_id="default", |
| category="test", |
| module_path="test.module", |
| class_name="TestClass", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
| db_session.refresh(agent) |
|
|
| |
| execution = create_execution_record( |
| db_session, |
| agent_id=str(agent.id), |
| status=initial_status, |
| started_at=datetime.utcnow() if initial_status == ExecutionStatus.RUNNING.value else None |
| ) |
|
|
| |
| final_status = ExecutionStatus.COMPLETED.value |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| result="Execution completed", |
| duration=1.0 |
| ) |
|
|
| |
| valid_transitions = { |
| ExecutionStatus.PENDING.value: [ |
| ExecutionStatus.RUNNING.value, |
| ExecutionStatus.FAILED.value, |
| ExecutionStatus.CANCELLED.value |
| ], |
| ExecutionStatus.RUNNING.value: [ |
| ExecutionStatus.COMPLETED.value, |
| ExecutionStatus.FAILED.value, |
| ExecutionStatus.CANCELLED.value |
| ] |
| } |
|
|
| |
| assert final_status in valid_transitions.get(initial_status, []), \ |
| f"Invalid state transition: {initial_status} → {final_status}" |
|
|
| |
| execution_after = db_session.query(AgentExecution).filter( |
| AgentExecution.id == execution.id |
| ).first() |
|
|
| assert execution_after.status != initial_status, \ |
| f"Execution still in initial state {initial_status}" |
|
|
| @given( |
| repeat_count=integers(min_value=2, max_value=10) |
| ) |
| @settings(**HYPOTHESIS_SETTINGS_STANDARD) |
| def test_execution_state_monotonic( |
| self, db_session: Session, repeat_count: int |
| ): |
| """ |
| PROPERTY: Execution state progression is monotonic (forward-only) |
| |
| STRATEGY: st.integers(2, 10) for repeat_count |
| |
| INVARIANT: State transitions only move forward |
| - No backward transitions (e.g., COMPLETED → RUNNING) |
| - No cycling between states |
| |
| RADII: 100 examples for state progression validation |
| |
| VALIDATED_BUG: None found (invariant holds) |
| """ |
| |
| agent = AgentRegistry( |
| name="MonotonicStateTestAgent", |
| tenant_id="default", |
| category="test", |
| module_path="test.module", |
| class_name="TestClass", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
| db_session.refresh(agent) |
|
|
| |
| state_order = { |
| ExecutionStatus.PENDING.value: 0, |
| ExecutionStatus.RUNNING.value: 1, |
| ExecutionStatus.COMPLETED.value: 2, |
| ExecutionStatus.FAILED.value: 2, |
| ExecutionStatus.CANCELLED.value: 2 |
| } |
|
|
| |
| execution = create_execution_record( |
| db_session, |
| agent_id=str(agent.id), |
| status=ExecutionStatus.PENDING.value |
| ) |
|
|
| |
| states = [ExecutionStatus.PENDING.value] |
|
|
| |
| for i in range(repeat_count): |
| execution_after = db_session.query(AgentExecution).filter( |
| AgentExecution.id == execution.id |
| ).first() |
|
|
| |
| if execution_after.status == ExecutionStatus.PENDING.value: |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| result="Transition to RUNNING", |
| duration=0.1 |
| ) |
| states.append(ExecutionStatus.RUNNING.value) |
| elif execution_after.status == ExecutionStatus.RUNNING.value: |
| simulate_execution( |
| db_session, |
| execution_id=execution.id, |
| result="Transition to COMPLETED", |
| duration=1.0 |
| ) |
| states.append(ExecutionStatus.COMPLETED.value) |
| else: |
| |
| break |
|
|
| |
| for i in range(1, len(states)): |
| prev_state = states[i-1] |
| curr_state = states[i] |
|
|
| prev_order = state_order[prev_state] |
| curr_order = state_order[curr_state] |
|
|
| assert curr_order >= prev_order, \ |
| f"Non-monotonic state transition: {prev_state} ({prev_order}) → {curr_state} ({curr_order})" |
|
|