feat(validation): implement Fix-then-Revalidate orchestration loop for required validations #711
@@ -2,6 +2,19 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added Fix-then-Revalidate orchestration loop for required validations:
|
||||
bounded retry with configurable limits (0--100 per Safety Profile),
|
||||
strategy revision escalation via ``auto_strategy_revision`` float
|
||||
threshold, user escalation via ``needs_user_escalation`` result flag,
|
||||
and domain events (``VALIDATION_FIX_ATTEMPTED``, ``VALIDATION_FIX_SUCCEEDED``,
|
||||
``VALIDATION_FIX_EXHAUSTED``). Validation errors are treated as required
|
||||
failures regardless of mode. Includes ``auto_validation_fix`` threshold,
|
||||
per-resource retry tracking, early-exit signalling via ``None`` return from
|
||||
``FixCallback``, event bus circuit breaker with lock-protected failure
|
||||
counter, spec-required ``validation_summary`` and
|
||||
``final_validation_results`` fields on the result model, DI container
|
||||
registration per ADR-003, and structured logging via ``structlog``.
|
||||
(#583)
|
||||
- Added LSP resource types: `executable`, `lsp-server`, `lsp-workspace`,
|
||||
`lsp-document` with parent/child hierarchy, auto-discovery rules, and
|
||||
handler references. Registered in bootstrap, with YAML configurations,
|
||||
@@ -361,6 +374,7 @@
|
||||
`integration_tests` and `slow_integration_tests` sessions. Fixture files are
|
||||
excluded from the main pabot runner via `tdd_fixture` tag. Includes 9 Robot
|
||||
Framework integration test cases. (#628)
|
||||
|
||||
- Added TDD-style failing Behave BDD tests for the session list DI container
|
||||
missing `db` provider bug. Three scenarios exercise `session list`,
|
||||
`_get_session_service()`, and `session list --format json` through the real
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""ASV benchmarks for the Fix-then-Revalidate orchestration loop.
|
||||
|
||||
Measures the performance of:
|
||||
- Orchestrator creation
|
||||
- Fix loop with immediate pass
|
||||
- Fix loop with multiple retries
|
||||
- Fix loop with terminal failure
|
||||
- Model creation and serialization
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixAttemptRecord,
|
||||
FixThenRevalidateOrchestrator,
|
||||
FixThenRevalidateResult,
|
||||
)
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixAttemptRecord,
|
||||
FixThenRevalidateOrchestrator,
|
||||
FixThenRevalidateResult,
|
||||
)
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_executor(validation_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"passed": True, "message": f"{validation_name} ok"}
|
||||
|
||||
|
||||
def _make_failed_result(name: str) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=name,
|
||||
resource_id="RES0001",
|
||||
resource_name="bench-resource",
|
||||
mode=ValidationMode.REQUIRED,
|
||||
passed=False,
|
||||
message=f"{name} failed",
|
||||
data={"hint": "bench"},
|
||||
duration_ms=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _always_fix(result: ValidationResult) -> str:
|
||||
return f"Fixed {result.validation_name}"
|
||||
|
||||
|
||||
class _CountingRevalidate:
|
||||
def __init__(self, pass_after: int) -> None:
|
||||
self._pass_after = pass_after
|
||||
self._count = 0
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
self._count += 1
|
||||
passed = self._count >= self._pass_after
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=passed,
|
||||
message="passed" if passed else "failing",
|
||||
data=result.data,
|
||||
duration_ms=1.0,
|
||||
)
|
||||
|
||||
|
||||
class _NeverPassRevalidate:
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=False,
|
||||
message="failing",
|
||||
data=result.data,
|
||||
duration_ms=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _build_pipeline() -> ValidationPipeline:
|
||||
return ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark suites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FixRevalidateCreationSuite:
|
||||
"""Benchmarks for orchestrator instantiation."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.pipeline = _build_pipeline()
|
||||
|
||||
def time_create_orchestrator_default(self) -> None:
|
||||
"""Benchmark creating an orchestrator with default settings."""
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=self.pipeline,
|
||||
)
|
||||
|
||||
def time_create_orchestrator_custom(self) -> None:
|
||||
"""Benchmark creating an orchestrator with custom settings."""
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=self.pipeline,
|
||||
max_retries=5,
|
||||
auto_strategy_revision=0.0,
|
||||
)
|
||||
|
||||
|
||||
class FixRevalidateLoopSuite:
|
||||
"""Benchmarks for the fix loop execution."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.pipeline = _build_pipeline()
|
||||
|
||||
def time_fix_first_attempt(self) -> None:
|
||||
"""Benchmark fix loop that succeeds on first attempt."""
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=self.pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
failed = [_make_failed_result("bench-check")]
|
||||
orchestrator.run_fix_loop(
|
||||
plan_id="BENCH001",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=1),
|
||||
)
|
||||
|
||||
def time_fix_on_second_attempt(self) -> None:
|
||||
"""Benchmark fix loop that succeeds on the second attempt."""
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=self.pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
failed = [_make_failed_result("bench-check")]
|
||||
orchestrator.run_fix_loop(
|
||||
plan_id="BENCH002",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=2),
|
||||
)
|
||||
|
||||
def time_terminal_failure(self) -> None:
|
||||
"""Benchmark fix loop that results in terminal failure."""
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=self.pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
failed = [_make_failed_result("bench-check")]
|
||||
orchestrator.run_fix_loop(
|
||||
plan_id="BENCH003",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_NeverPassRevalidate(),
|
||||
)
|
||||
|
||||
def time_empty_failures(self) -> None:
|
||||
"""Benchmark fix loop with no failures (immediate pass)."""
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=self.pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
orchestrator.run_fix_loop(
|
||||
plan_id="BENCH004",
|
||||
failed_results=[],
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=1),
|
||||
)
|
||||
|
||||
|
||||
class FixRevalidateModelSuite:
|
||||
"""Benchmarks for model creation and serialization."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create_attempt_record(self) -> None:
|
||||
"""Benchmark creating a FixAttemptRecord."""
|
||||
FixAttemptRecord(
|
||||
attempt_number=1,
|
||||
validation_name="bench-check",
|
||||
fix_description="Applied fix",
|
||||
success=True,
|
||||
)
|
||||
|
||||
def time_create_result_model(self) -> None:
|
||||
"""Benchmark creating a FixThenRevalidateResult."""
|
||||
records = [
|
||||
FixAttemptRecord(
|
||||
attempt_number=i + 1,
|
||||
validation_name=f"check-{i}",
|
||||
fix_description=f"Fix {i}",
|
||||
success=i == 2,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
FixThenRevalidateResult(
|
||||
validation_attempts=3,
|
||||
final_passed=True,
|
||||
validation_fix_history=records,
|
||||
escalated=False,
|
||||
terminal_failure=False,
|
||||
)
|
||||
|
||||
def time_result_model_dump(self) -> None:
|
||||
"""Benchmark serializing FixThenRevalidateResult to dict."""
|
||||
records = [
|
||||
FixAttemptRecord(
|
||||
attempt_number=1,
|
||||
validation_name="check-a",
|
||||
fix_description="Fix a",
|
||||
success=True,
|
||||
),
|
||||
]
|
||||
result = FixThenRevalidateResult(
|
||||
validation_attempts=1,
|
||||
final_passed=True,
|
||||
validation_fix_history=records,
|
||||
)
|
||||
result.model_dump()
|
||||
@@ -0,0 +1,61 @@
|
||||
Feature: Fix-then-Revalidate orchestration loop
|
||||
As an execution actor
|
||||
I want a bounded retry loop that diagnoses, fixes, and re-validates
|
||||
So that recoverable validation failures are resolved automatically
|
||||
|
||||
Background:
|
||||
Given a fix-revalidate orchestrator with max_retries 3
|
||||
|
||||
# Happy path: first fix attempt resolves the validation failure
|
||||
Scenario: Fix succeeds on first attempt
|
||||
Given a required validation "check-syntax" that fails initially
|
||||
And a fix callback that always succeeds
|
||||
And a revalidate callback that passes after 1 fix
|
||||
When the fix-revalidate loop runs for plan "PLAN001"
|
||||
Then the fix-revalidate result should have validation_attempts 1
|
||||
And the fix-revalidate result should have final_passed True
|
||||
And the fix-revalidate result should not be escalated
|
||||
And the fix-revalidate result should not be terminal failure
|
||||
|
||||
# Multiple attempts needed before the fix works
|
||||
Scenario: Fix succeeds after multiple attempts
|
||||
Given a required validation "check-lint" that fails initially
|
||||
And a fix callback that always succeeds
|
||||
And a revalidate callback that passes after 2 fixes
|
||||
When the fix-revalidate loop runs for plan "PLAN002"
|
||||
Then the fix-revalidate result should have validation_attempts 2
|
||||
And the fix-revalidate result should have final_passed True
|
||||
And the validation fix history should have 2 records
|
||||
|
||||
# Retry limit reached, auto_strategy_revision triggers escalation
|
||||
Scenario: Retry limit exhausted with strategy revision
|
||||
Given a fix-revalidate orchestrator with auto_strategy_revision enabled
|
||||
And a required validation "check-security" that fails initially
|
||||
And a fix callback that always succeeds
|
||||
And a revalidate callback that never passes
|
||||
When the fix-revalidate loop runs for plan "PLAN003"
|
||||
Then the fix-revalidate result should have final_passed False
|
||||
And the fix-revalidate result should be escalated
|
||||
And the fix-revalidate result should not be terminal failure
|
||||
And the fix-revalidate result should have validation_attempts 3
|
||||
|
||||
# Terminal failure when all attempts fail and no strategy revision
|
||||
Scenario: Terminal failure when all attempts fail
|
||||
Given a required validation "check-deploy" that fails initially
|
||||
And a fix callback that always succeeds
|
||||
And a revalidate callback that never passes
|
||||
When the fix-revalidate loop runs for plan "PLAN004"
|
||||
Then the fix-revalidate result should have final_passed False
|
||||
And the fix-revalidate result should need user escalation
|
||||
And the fix-revalidate result should not be terminal failure
|
||||
And the validation fix history should have 3 records
|
||||
|
||||
# Informational validations do not trigger the fix loop
|
||||
Scenario: Informational validations do not trigger fix loop
|
||||
Given an informational validation "check-style" that fails
|
||||
And a fix callback that always succeeds
|
||||
And a revalidate callback that passes after 1 fix
|
||||
When the fix-revalidate loop runs for plan "PLAN005"
|
||||
Then the fix-revalidate result should have validation_attempts 0
|
||||
And the fix-revalidate result should have final_passed True
|
||||
And the validation fix history should have 0 records
|
||||
@@ -0,0 +1,348 @@
|
||||
Feature: Fix-then-Revalidate coverage boost
|
||||
As a developer
|
||||
I want to cover edge cases and validation paths in FixThenRevalidateOrchestrator
|
||||
So that code coverage meets the 97% threshold
|
||||
|
||||
# Covers: __init__ validation
|
||||
Scenario: Orchestrator rejects None validation_pipeline
|
||||
When the fix-revalidate orchestrator is created with None pipeline
|
||||
Then a fix-revalidate validation error should be raised with "validation_pipeline must not be None"
|
||||
|
||||
# Covers: __init__ validation
|
||||
Scenario: Orchestrator rejects non-integer max_retries
|
||||
When the fix-revalidate orchestrator is created with non-integer max_retries
|
||||
Then a fix-revalidate validation error should be raised with "max_retries must be an integer"
|
||||
|
||||
# Covers: __init__ validation
|
||||
Scenario: Orchestrator rejects max_retries below minimum
|
||||
When the fix-revalidate orchestrator is created with max_retries -1
|
||||
Then a fix-revalidate validation error should be raised with "max_retries must be between"
|
||||
|
||||
# Covers: __init__ validation
|
||||
Scenario: Orchestrator rejects max_retries above maximum
|
||||
When the fix-revalidate orchestrator is created with max_retries 101
|
||||
Then a fix-revalidate validation error should be raised with "max_retries must be between"
|
||||
|
||||
# Covers: max_retries property
|
||||
Scenario: Orchestrator exposes max_retries property
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 5
|
||||
Then the fix-revalidate max_retries property should return 5
|
||||
|
||||
# Covers: auto_strategy_revision property
|
||||
Scenario: Orchestrator exposes auto_strategy_revision property
|
||||
Given a fix-revalidate coverage orchestrator with auto_strategy_revision enabled
|
||||
Then the fix-revalidate auto_strategy_revision property should be 0.0
|
||||
|
||||
# Covers: get_retry_count empty plan_id
|
||||
Scenario: get_retry_count rejects empty plan_id
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate get_retry_count is called with empty plan_id
|
||||
Then a fix-revalidate validation error should be raised with "plan_id must not be empty"
|
||||
|
||||
# Covers: get_retry_count empty validation_name
|
||||
Scenario: get_retry_count rejects empty validation_name
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate get_retry_count is called with empty validation_name
|
||||
Then a fix-revalidate validation error should be raised with "validation_name must not be empty"
|
||||
|
||||
# Covers: get_retry_count returns count
|
||||
Scenario: get_retry_count returns zero for unseen validation
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
Then the fix-revalidate get_retry_count for plan "P1" validation "check-a" should be 0
|
||||
|
||||
# Covers: reset_retry_counts empty plan_id
|
||||
Scenario: reset_retry_counts rejects empty plan_id
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate reset_retry_counts is called with empty plan_id
|
||||
Then a fix-revalidate validation error should be raised with "plan_id must not be empty"
|
||||
|
||||
# Covers: run_fix_loop empty plan_id
|
||||
Scenario: run_fix_loop rejects empty plan_id
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate run_fix_loop is called with empty plan_id
|
||||
Then a fix-revalidate validation error should be raised with "plan_id must not be empty"
|
||||
|
||||
# Covers: run_fix_loop None failed_results
|
||||
Scenario: run_fix_loop rejects None failed_results
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate run_fix_loop is called with None failed_results
|
||||
Then a fix-revalidate validation error should be raised with "failed_results must not be None"
|
||||
|
||||
# Covers: run_fix_loop None fix_callback
|
||||
Scenario: run_fix_loop rejects None fix_callback
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate run_fix_loop is called with None fix_callback
|
||||
Then a fix-revalidate validation error should be raised with "fix_callback must not be None"
|
||||
|
||||
# Covers: run_fix_loop None revalidate_callback
|
||||
Scenario: run_fix_loop rejects None revalidate_callback
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate run_fix_loop is called with None revalidate_callback
|
||||
Then a fix-revalidate validation error should be raised with "revalidate_callback must not be None"
|
||||
|
||||
# Covers: fix_callback exception handling
|
||||
Scenario: fix_callback exception is treated as failed attempt
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
And a required coverage validation "check-syntax" that fails
|
||||
And a fix-revalidate coverage fix callback that raises an exception
|
||||
And a fix-revalidate coverage revalidate callback that never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-EXC1"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
And the fix-revalidate coverage validation fix history should have 3 records
|
||||
And each fix-revalidate coverage validation fix history record should have success False
|
||||
|
||||
# Covers: revalidate_callback exception handling
|
||||
Scenario: revalidate_callback exception is treated as failed validation
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
And a required coverage validation "check-lint" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that raises an exception
|
||||
When the fix-revalidate coverage loop runs for plan "P-EXC2"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
And the fix-revalidate coverage validation fix history should have 3 records
|
||||
|
||||
# Covers: multiple simultaneous validation failures
|
||||
Scenario: Multiple validations fail simultaneously with mixed outcomes
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
And a required coverage validation "check-a" that fails
|
||||
And a required coverage validation "check-b" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback where check-a passes after 1 and check-b never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-MULTI"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
And the fix-revalidate coverage validation fix history should have 4 records
|
||||
|
||||
# Covers: reset_retry_counts then re-run
|
||||
Scenario: Reset retry counts allows fresh retry budget
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 2
|
||||
And a required coverage validation "check-retry" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-RESET"
|
||||
And the fix-revalidate coverage retry counts are reset for plan "P-RESET"
|
||||
And a fix-revalidate coverage revalidate callback that passes after 1
|
||||
And the fix-revalidate coverage loop runs again for plan "P-RESET" with validation "check-retry"
|
||||
Then the fix-revalidate coverage result should have final_passed True
|
||||
And the fix-revalidate coverage result should have validation_attempts 1
|
||||
|
||||
# Covers: validation fix history record field validation
|
||||
Scenario: Fix history records contain correct field values
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
And a required coverage validation "check-fields" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that passes after 2
|
||||
When the fix-revalidate coverage loop runs for plan "P-FIELDS"
|
||||
Then the fix-revalidate coverage validation fix history should have 2 records
|
||||
And fix-revalidate coverage validation fix history record 1 should have validation_name "check-fields"
|
||||
And fix-revalidate coverage validation fix history record 1 should have attempt_number 1
|
||||
And fix-revalidate coverage validation fix history record 1 should have success False
|
||||
And fix-revalidate coverage validation fix history record 2 should have success True
|
||||
|
||||
# Covers: max_retries=1 boundary
|
||||
Scenario: Fix loop works with max_retries 1
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1
|
||||
And a required coverage validation "check-bound1" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that passes after 1
|
||||
When the fix-revalidate coverage loop runs for plan "P-BOUND1"
|
||||
Then the fix-revalidate coverage result should have final_passed True
|
||||
And the fix-revalidate coverage result should have validation_attempts 1
|
||||
|
||||
# Covers: max_retries=10 boundary
|
||||
Scenario: Fix loop works with max_retries 10
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 10
|
||||
And a required coverage validation "check-bound10" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that passes after 9
|
||||
When the fix-revalidate coverage loop runs for plan "P-BOUND10"
|
||||
Then the fix-revalidate coverage result should have final_passed True
|
||||
And the fix-revalidate coverage result should have validation_attempts 9
|
||||
|
||||
# Covers: event_bus property
|
||||
Scenario: Orchestrator exposes event_bus property
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
Then the fix-revalidate event_bus property should be None
|
||||
|
||||
# Covers: exhausted re-invocation with auto-reset
|
||||
Scenario: Second invocation for exhausted validation logs warning
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1
|
||||
And a required coverage validation "check-exhausted" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-EXHAUST"
|
||||
And the fix-revalidate coverage loop runs again for plan "P-EXHAUST" with validation "check-exhausted"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should have validation_attempts 1
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
|
||||
# Covers: bool type rejected for max_retries
|
||||
Scenario: Orchestrator rejects boolean max_retries True
|
||||
When the fix-revalidate orchestrator is created with boolean max_retries True
|
||||
Then a fix-revalidate validation error should be raised with "max_retries must be an integer"
|
||||
|
||||
Scenario: Orchestrator rejects boolean max_retries False
|
||||
When the fix-revalidate orchestrator is created with boolean max_retries False
|
||||
Then a fix-revalidate validation error should be raised with "max_retries must be an integer"
|
||||
|
||||
# Covers: EventBus domain event emission
|
||||
Scenario: EventBus receives VALIDATION_FIX_ATTEMPTED and SUCCEEDED events on fix success
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3 and a mock event bus
|
||||
And a required coverage validation "check-events" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that passes after 1
|
||||
When the fix-revalidate coverage loop runs for plan "P-EVENTS"
|
||||
Then the mock event bus should have received 2 events
|
||||
And mock event bus event 1 should have event_type "validation.fix_attempted"
|
||||
And mock event bus event 2 should have event_type "validation.fix_succeeded"
|
||||
|
||||
Scenario: EventBus receives VALIDATION_FIX_EXHAUSTED event on exhaustion
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1 and a mock event bus
|
||||
And a required coverage validation "check-exhaust-evt" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-EXHEVT"
|
||||
Then the fix-revalidate coverage result should need user escalation
|
||||
And the mock event bus should have received 2 events
|
||||
And mock event bus event 1 should have event_type "validation.fix_attempted"
|
||||
And mock event bus event 2 should have event_type "validation.fix_exhausted"
|
||||
|
||||
Scenario: EventBus emit exception does not crash the fix loop
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1 and a failing event bus
|
||||
And a required coverage validation "check-bus-err" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that passes after 1
|
||||
When the fix-revalidate coverage loop runs for plan "P-BUSERR"
|
||||
Then the fix-revalidate coverage result should have final_passed True
|
||||
|
||||
# Covers: FixAttemptRecord Pydantic field constraints
|
||||
Scenario: FixAttemptRecord rejects attempt_number below 1
|
||||
When a fix-revalidate FixAttemptRecord is created with attempt_number 0
|
||||
Then a fix-revalidate pydantic validation error should be raised
|
||||
|
||||
Scenario: FixAttemptRecord rejects whitespace-only validation_name
|
||||
When a fix-revalidate FixAttemptRecord is created with whitespace validation_name
|
||||
Then a fix-revalidate pydantic validation error should be raised
|
||||
|
||||
# Covers: fix_description truncation to 2000 chars
|
||||
Scenario: Fix callback returning over-length description is truncated
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1
|
||||
And a required coverage validation "check-trunc" that fails
|
||||
And a fix-revalidate coverage fix callback that returns a 3000 char description
|
||||
And a fix-revalidate coverage revalidate callback that passes after 1
|
||||
When the fix-revalidate coverage loop runs for plan "P-TRUNC"
|
||||
Then the fix-revalidate coverage result should have final_passed True
|
||||
And the fix-revalidate coverage validation fix history should have 1 records
|
||||
And fix-revalidate coverage validation fix history record 1 fix_description should be at most 2000 chars
|
||||
|
||||
# Covers: FixThenRevalidateResult model_validator
|
||||
Scenario: FixThenRevalidateResult rejects escalated and terminal_failure both True
|
||||
When a fix-revalidate FixThenRevalidateResult is created with escalated True and terminal_failure True
|
||||
Then a fix-revalidate pydantic validation error should be raised
|
||||
|
||||
# Covers: validation_name max_length on FixAttemptRecord
|
||||
Scenario: FixAttemptRecord rejects validation_name exceeding 255 chars
|
||||
When a fix-revalidate FixAttemptRecord is created with validation_name exceeding 255 chars
|
||||
Then a fix-revalidate pydantic validation error should be raised
|
||||
|
||||
# Covers: revalidate callback returning mismatched validation_name
|
||||
Scenario: Revalidate callback returning wrong validation_name is treated as failure
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 2
|
||||
And a required coverage validation "check-mismatch" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that returns a different validation_name
|
||||
When the fix-revalidate coverage loop runs for plan "P-MISMATCH"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
|
||||
# Covers: auto_strategy_revision type validation
|
||||
Scenario: Orchestrator rejects boolean auto_strategy_revision
|
||||
When the fix-revalidate orchestrator is created with boolean auto_strategy_revision
|
||||
Then a fix-revalidate validation error should be raised with "auto_strategy_revision must be a float"
|
||||
|
||||
# Covers: auto_strategy_revision range validation
|
||||
Scenario: Orchestrator rejects auto_strategy_revision out of range
|
||||
When the fix-revalidate orchestrator is created with auto_strategy_revision 1.5
|
||||
Then a fix-revalidate validation error should be raised with "auto_strategy_revision must be between"
|
||||
|
||||
# Covers: auto_validation_fix type validation
|
||||
Scenario: Orchestrator rejects boolean auto_validation_fix
|
||||
When the fix-revalidate orchestrator is created with boolean auto_validation_fix
|
||||
Then a fix-revalidate validation error should be raised with "auto_validation_fix must be a float"
|
||||
|
||||
# Covers: auto_validation_fix range validation
|
||||
Scenario: Orchestrator rejects auto_validation_fix out of range
|
||||
When the fix-revalidate orchestrator is created with auto_validation_fix -0.1
|
||||
Then a fix-revalidate validation error should be raised with "auto_validation_fix must be between"
|
||||
|
||||
# Covers: event_bus type validation
|
||||
Scenario: Orchestrator rejects non-EventBus event_bus
|
||||
When the fix-revalidate orchestrator is created with invalid event_bus type
|
||||
Then a fix-revalidate validation error should be raised with "event_bus must be an EventBus"
|
||||
|
||||
# Covers: callable() validation on fix_callback
|
||||
Scenario: run_fix_loop rejects non-callable fix_callback
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate run_fix_loop is called with non-callable fix_callback
|
||||
Then a fix-revalidate validation error should be raised with "fix_callback must be callable"
|
||||
|
||||
# Covers: callable() validation on revalidate_callback
|
||||
Scenario: run_fix_loop rejects non-callable revalidate_callback
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
When the fix-revalidate run_fix_loop is called with non-callable revalidate_callback
|
||||
Then a fix-revalidate validation error should be raised with "revalidate_callback must be callable"
|
||||
|
||||
# Covers: fix_callback returning None signals unfixable
|
||||
Scenario: Fix callback returning None escalates immediately
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
And a required coverage validation "check-unfixable" that fails
|
||||
And a fix-revalidate coverage fix callback that returns None
|
||||
And a fix-revalidate coverage revalidate callback that never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-UNFIXABLE"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
And the fix-revalidate coverage validation fix history should have 1 records
|
||||
|
||||
# Covers: passed=True with error treated as failure
|
||||
Scenario: Revalidate returning passed True with error is treated as failure
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1
|
||||
And a required coverage validation "check-contradictory" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that returns passed with error
|
||||
When the fix-revalidate coverage loop runs for plan "P-CONTRADICT"
|
||||
Then the fix-revalidate coverage result should have final_passed False
|
||||
And the fix-revalidate coverage result should need user escalation
|
||||
|
||||
# Covers: final_passed=True with escalated=True rejected
|
||||
Scenario: FixThenRevalidateResult rejects final_passed True with escalated True
|
||||
When a fix-revalidate FixThenRevalidateResult is created with final_passed True and escalated True
|
||||
Then a fix-revalidate pydantic validation error should be raised
|
||||
|
||||
# Covers: auto_validation_fix property accessor
|
||||
Scenario: Orchestrator exposes auto_validation_fix property
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
Then the fix-revalidate auto_validation_fix property should be 0.0
|
||||
|
||||
# Covers: EventBus receives VALIDATION_FIX_EXHAUSTED on escalation path
|
||||
Scenario: EventBus receives VALIDATION_FIX_EXHAUSTED event on escalation
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 1 and mock event bus and escalation
|
||||
And a required coverage validation "check-esc-evt" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that never passes
|
||||
When the fix-revalidate coverage loop runs for plan "P-ESCEVT"
|
||||
Then the mock event bus should have received 2 events
|
||||
And mock event bus event 1 should have event_type "validation.fix_attempted"
|
||||
And mock event bus event 2 should have event_type "validation.fix_exhausted"
|
||||
|
||||
# Covers: mixed required and informational failures
|
||||
Scenario: Mixed required and informational failures only fix required
|
||||
Given a fix-revalidate coverage orchestrator with max_retries 3
|
||||
And a required coverage validation "check-req" that fails
|
||||
And an informational coverage validation "check-info" that fails
|
||||
And a fix-revalidate coverage fix callback that always succeeds
|
||||
And a fix-revalidate coverage revalidate callback that passes after 1
|
||||
When the fix-revalidate coverage loop runs for plan "P-MIXED"
|
||||
Then the fix-revalidate coverage result should have final_passed True
|
||||
And the fix-revalidate coverage result should have validation_attempts 1
|
||||
@@ -0,0 +1,962 @@
|
||||
"""Step definitions for Fix-then-Revalidate coverage boost scenarios.
|
||||
|
||||
Covers validation edge cases, property accessors, error paths,
|
||||
callback exception handling, multi-failure scenarios, and boundary
|
||||
tests to ensure >=97% coverage on fix_then_revalidate.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixAttemptRecord,
|
||||
FixThenRevalidateOrchestrator,
|
||||
FixThenRevalidateResult,
|
||||
)
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
)
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_executor(validation_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"passed": True, "message": f"{validation_name} ok"}
|
||||
|
||||
|
||||
def _make_pipeline() -> ValidationPipeline:
|
||||
return ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
|
||||
|
||||
def _always_fix(result: ValidationResult) -> str:
|
||||
return f"Fixed {result.validation_name}"
|
||||
|
||||
|
||||
def _always_pass(result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=True,
|
||||
message="passed",
|
||||
data=result.data,
|
||||
duration_ms=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_failed_result(
|
||||
name: str,
|
||||
mode: ValidationMode = ValidationMode.REQUIRED,
|
||||
) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=name,
|
||||
resource_id="RES0001",
|
||||
resource_name="test-resource",
|
||||
mode=mode,
|
||||
passed=False,
|
||||
message=f"{name} failed: expected condition not met",
|
||||
data={"hint": "check configuration"},
|
||||
duration_ms=10.0,
|
||||
)
|
||||
|
||||
|
||||
def _raising_fix(result: ValidationResult) -> str:
|
||||
raise RuntimeError(f"Fix callback crash for {result.validation_name}")
|
||||
|
||||
|
||||
class _RaisingRevalidateCallback:
|
||||
"""Revalidate callback that always raises."""
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
raise RuntimeError(f"Revalidate callback crash for {result.validation_name}")
|
||||
|
||||
|
||||
class _NeverPassRevalidateCallback:
|
||||
"""Revalidate callback that never passes."""
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=False,
|
||||
message="still failing",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
class _CountingRevalidateCallback:
|
||||
"""Revalidate callback that passes after N calls."""
|
||||
|
||||
def __init__(self, pass_after: int) -> None:
|
||||
self._pass_after = pass_after
|
||||
self._count = 0
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
self._count += 1
|
||||
passed = self._count >= self._pass_after
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=passed,
|
||||
message=f"{'passed' if passed else 'still failing'} "
|
||||
f"(attempt {self._count})",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
class _PerValidationRevalidateCallback:
|
||||
"""Revalidate callback with per-validation pass-after config."""
|
||||
|
||||
def __init__(self, config: dict[str, int | None]) -> None:
|
||||
self._config = config
|
||||
self._counts: dict[str, int] = {}
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
vname = result.validation_name
|
||||
self._counts[vname] = self._counts.get(vname, 0) + 1
|
||||
pass_after = self._config.get(vname)
|
||||
passed = pass_after is not None and self._counts[vname] >= pass_after
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=passed,
|
||||
message=f"{'passed' if passed else 'still failing'} "
|
||||
f"(attempt {self._counts[vname]})",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage orchestrator with max_retries {n:d}")
|
||||
def step_given_coverage_orchestrator(context: Context, n: int) -> None:
|
||||
context.frc_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=n,
|
||||
auto_strategy_revision=1.0,
|
||||
)
|
||||
context.frc_error = None
|
||||
context.frc_failed_results = []
|
||||
context.frc_fix_callback = _always_fix
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage orchestrator with auto_strategy_revision enabled")
|
||||
def step_given_coverage_orchestrator_asr(context: Context) -> None:
|
||||
context.frc_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=3,
|
||||
auto_strategy_revision=0.0,
|
||||
)
|
||||
context.frc_error = None
|
||||
context.frc_failed_results = []
|
||||
context.frc_fix_callback = _always_fix
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
@given('a required coverage validation "{name}" that fails')
|
||||
def step_given_required_coverage_validation(context: Context, name: str) -> None:
|
||||
context.frc_failed_results.append(_make_failed_result(name))
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage fix callback that raises an exception")
|
||||
def step_given_raising_fix_callback(context: Context) -> None:
|
||||
context.frc_fix_callback = _raising_fix
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage fix callback that always succeeds")
|
||||
def step_given_always_fix_callback(context: Context) -> None:
|
||||
context.frc_fix_callback = _always_fix
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage revalidate callback that never passes")
|
||||
def step_given_never_pass_revalidate(context: Context) -> None:
|
||||
context.frc_revalidate_callback = _NeverPassRevalidateCallback()
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage revalidate callback that raises an exception")
|
||||
def step_given_raising_revalidate(context: Context) -> None:
|
||||
context.frc_revalidate_callback = _RaisingRevalidateCallback()
|
||||
|
||||
|
||||
@given(
|
||||
"a fix-revalidate coverage revalidate callback where "
|
||||
"check-a passes after 1 and check-b never passes"
|
||||
)
|
||||
def step_given_per_validation_revalidate(context: Context) -> None:
|
||||
context.frc_revalidate_callback = _PerValidationRevalidateCallback(
|
||||
config={"check-a": 1, "check-b": None}
|
||||
)
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage revalidate callback that passes after {n:d}")
|
||||
def step_given_counting_revalidate(context: Context, n: int) -> None:
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=n)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps (constructor validation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with None pipeline")
|
||||
def step_when_create_none_pipeline(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_pipeline: Any = None
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=bad_pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with non-integer max_retries")
|
||||
def step_when_create_non_int_max_retries(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_max_retries: Any = "three"
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=bad_max_retries,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with max_retries {n:d}")
|
||||
def step_when_create_bad_max_retries(context: Context, n: int) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=n,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps (method validation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("the fix-revalidate get_retry_count is called with empty plan_id")
|
||||
def step_when_get_retry_count_empty_plan(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
context.frc_orchestrator.get_retry_count("", "check-a")
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate get_retry_count is called with empty validation_name")
|
||||
def step_when_get_retry_count_empty_vname(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
context.frc_orchestrator.get_retry_count("P1", "")
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate reset_retry_counts is called with empty plan_id")
|
||||
def step_when_reset_empty_plan(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
context.frc_orchestrator.reset_retry_counts("")
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate run_fix_loop is called with empty plan_id")
|
||||
def step_when_run_loop_empty_plan(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
context.frc_orchestrator.run_fix_loop(
|
||||
plan_id="",
|
||||
failed_results=[],
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_always_pass,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate run_fix_loop is called with None failed_results")
|
||||
def step_when_run_loop_none_results(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_results: Any = None
|
||||
context.frc_orchestrator.run_fix_loop(
|
||||
plan_id="P1",
|
||||
failed_results=bad_results,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_always_pass,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate run_fix_loop is called with None fix_callback")
|
||||
def step_when_run_loop_none_fix(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_callback: Any = None
|
||||
context.frc_orchestrator.run_fix_loop(
|
||||
plan_id="P1",
|
||||
failed_results=[],
|
||||
fix_callback=bad_callback,
|
||||
revalidate_callback=_always_pass,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate run_fix_loop is called with None revalidate_callback")
|
||||
def step_when_run_loop_none_revalidate(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_callback: Any = None
|
||||
context.frc_orchestrator.run_fix_loop(
|
||||
plan_id="P1",
|
||||
failed_results=[],
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=bad_callback,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when('the fix-revalidate coverage loop runs for plan "{plan_id}"')
|
||||
def step_when_run_coverage_loop(context: Context, plan_id: str) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_orchestrator.run_fix_loop(
|
||||
plan_id=plan_id,
|
||||
failed_results=context.frc_failed_results,
|
||||
fix_callback=context.frc_fix_callback,
|
||||
revalidate_callback=context.frc_revalidate_callback,
|
||||
)
|
||||
context.frc_result = result
|
||||
|
||||
|
||||
@when('the fix-revalidate coverage retry counts are reset for plan "{plan_id}"')
|
||||
def step_when_reset_coverage_counts(context: Context, plan_id: str) -> None:
|
||||
context.frc_orchestrator.reset_retry_counts(plan_id)
|
||||
|
||||
|
||||
@when("a fix-revalidate coverage revalidate callback that passes after {n:d}")
|
||||
def step_when_set_counting_revalidate(context: Context, n: int) -> None:
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=n)
|
||||
|
||||
|
||||
@when(
|
||||
'the fix-revalidate coverage loop runs again for plan "{plan_id}" '
|
||||
'with validation "{vname}"'
|
||||
)
|
||||
def step_when_run_coverage_loop_again(
|
||||
context: Context, plan_id: str, vname: str
|
||||
) -> None:
|
||||
failed_results = [_make_failed_result(vname)]
|
||||
result: FixThenRevalidateResult = context.frc_orchestrator.run_fix_loop(
|
||||
plan_id=plan_id,
|
||||
failed_results=failed_results,
|
||||
fix_callback=context.frc_fix_callback,
|
||||
revalidate_callback=context.frc_revalidate_callback,
|
||||
)
|
||||
context.frc_result = result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('a fix-revalidate validation error should be raised with "{text}"')
|
||||
def step_then_validation_error(context: Context, text: str) -> None:
|
||||
assert context.frc_error is not None, (
|
||||
"Expected a ValidationError but none was raised"
|
||||
)
|
||||
assert text in context.frc_error, (
|
||||
f"Expected error containing '{text}', got '{context.frc_error}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate max_retries property should return {n:d}")
|
||||
def step_then_max_retries_property(context: Context, n: int) -> None:
|
||||
assert context.frc_orchestrator.max_retries == n, (
|
||||
f"Expected max_retries={n}, got {context.frc_orchestrator.max_retries}"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate auto_strategy_revision property should be 0.0")
|
||||
def step_then_asr_property(context: Context) -> None:
|
||||
assert context.frc_orchestrator.auto_strategy_revision == 0.0, (
|
||||
"Expected auto_strategy_revision=0.0"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
'the fix-revalidate get_retry_count for plan "{plan_id}" '
|
||||
'validation "{vname}" should be {n:d}'
|
||||
)
|
||||
def step_then_retry_count(context: Context, plan_id: str, vname: str, n: int) -> None:
|
||||
count = context.frc_orchestrator.get_retry_count(plan_id, vname)
|
||||
assert count == n, f"Expected retry count {n}, got {count}"
|
||||
|
||||
|
||||
@then("the fix-revalidate coverage result should have final_passed {expected}")
|
||||
def step_then_coverage_final_passed(context: Context, expected: str) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
expected_bool = expected.strip().lower() == "true"
|
||||
assert result.final_passed is expected_bool, (
|
||||
f"Expected final_passed={expected_bool}, got {result.final_passed}"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate coverage result should be terminal failure")
|
||||
def step_then_coverage_terminal(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
assert result.terminal_failure is True, "Expected terminal_failure=True"
|
||||
|
||||
|
||||
@then("the fix-revalidate coverage result should not be terminal failure")
|
||||
def step_then_coverage_not_terminal(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
assert result.terminal_failure is False, "Expected terminal_failure=False"
|
||||
|
||||
|
||||
@then("the fix-revalidate coverage result should need user escalation")
|
||||
def step_then_coverage_needs_user_escalation(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
assert result.needs_user_escalation is True, "Expected needs_user_escalation=True"
|
||||
|
||||
|
||||
@then("the fix-revalidate coverage validation fix history should have {n:d} records")
|
||||
def step_then_coverage_fix_history_count(context: Context, n: int) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
assert len(result.validation_fix_history) == n, (
|
||||
f"Expected {n} fix history records, got {len(result.validation_fix_history)}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"each fix-revalidate coverage validation fix history record "
|
||||
"should have success False"
|
||||
)
|
||||
def step_then_all_records_failed(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
for i, rec in enumerate(result.validation_fix_history):
|
||||
assert rec.success is False, (
|
||||
f"Record {i + 1} has success={rec.success}, expected False"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate coverage result should have validation_attempts {n:d}")
|
||||
def step_then_coverage_total_attempts(context: Context, n: int) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
assert result.validation_attempts == n, (
|
||||
f"Expected validation_attempts={n}, got {result.validation_attempts}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"fix-revalidate coverage validation fix history record {idx:d} "
|
||||
'should have validation_name "{name}"'
|
||||
)
|
||||
def step_then_record_vname(context: Context, idx: int, name: str) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
record = result.validation_fix_history[idx - 1]
|
||||
assert record.validation_name == name, (
|
||||
f"Record {idx} validation_name={record.validation_name}, expected {name}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"fix-revalidate coverage validation fix history record {idx:d} "
|
||||
"should have attempt_number {n:d}"
|
||||
)
|
||||
def step_then_record_attempt_number(context: Context, idx: int, n: int) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
record = result.validation_fix_history[idx - 1]
|
||||
assert record.attempt_number == n, (
|
||||
f"Record {idx} attempt_number={record.attempt_number}, expected {n}"
|
||||
)
|
||||
|
||||
|
||||
@then(
|
||||
"fix-revalidate coverage validation fix history record {idx:d} "
|
||||
"should have success {expected}"
|
||||
)
|
||||
def step_then_record_success(context: Context, idx: int, expected: str) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
record = result.validation_fix_history[idx - 1]
|
||||
expected_bool = expected.strip().lower() == "true"
|
||||
assert record.success is expected_bool, (
|
||||
f"Record {idx} success={record.success}, expected {expected_bool}"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate event_bus property should be None")
|
||||
def step_then_event_bus_none(context: Context) -> None:
|
||||
assert context.frc_orchestrator.event_bus is None, "Expected event_bus=None"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock EventBus for M4 review fix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MockEventBus:
|
||||
"""Mock EventBus that records emitted events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: list[DomainEvent] = []
|
||||
|
||||
def emit(self, event: DomainEvent) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: Any,
|
||||
) -> None:
|
||||
pass # Not needed for this test
|
||||
|
||||
|
||||
class _FailingEventBus:
|
||||
"""Mock EventBus whose emit always raises."""
|
||||
|
||||
def emit(self, event: DomainEvent) -> None:
|
||||
raise RuntimeError("EventBus emit failure")
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
event_type: EventType,
|
||||
handler: Any,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps for new review-fix scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
"a fix-revalidate coverage orchestrator with max_retries {n:d} and a mock event bus"
|
||||
)
|
||||
def step_given_coverage_orchestrator_with_mock_bus(context: Context, n: int) -> None:
|
||||
context.frc_mock_event_bus = _MockEventBus()
|
||||
context.frc_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=n,
|
||||
auto_strategy_revision=1.0,
|
||||
event_bus=context.frc_mock_event_bus,
|
||||
)
|
||||
context.frc_error = None
|
||||
context.frc_failed_results = []
|
||||
context.frc_fix_callback = _always_fix
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
@given(
|
||||
"a fix-revalidate coverage orchestrator with max_retries {n:d} "
|
||||
"and a failing event bus"
|
||||
)
|
||||
def step_given_coverage_orchestrator_with_failing_bus(context: Context, n: int) -> None:
|
||||
context.frc_mock_event_bus = _FailingEventBus()
|
||||
context.frc_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
auto_strategy_revision=1.0,
|
||||
max_retries=n,
|
||||
event_bus=context.frc_mock_event_bus,
|
||||
)
|
||||
context.frc_error = None
|
||||
context.frc_failed_results = []
|
||||
context.frc_fix_callback = _always_fix
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps for new review-fix scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with boolean max_retries True")
|
||||
def step_when_create_bool_true_max_retries(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_retries: Any = True
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=bad_retries,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with boolean max_retries False")
|
||||
def step_when_create_bool_false_max_retries(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_retries: Any = False
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=bad_retries,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("a fix-revalidate FixAttemptRecord is created with attempt_number 0")
|
||||
def step_when_create_record_bad_attempt(context: Context) -> None:
|
||||
context.frc_pydantic_error = None
|
||||
try:
|
||||
FixAttemptRecord(
|
||||
attempt_number=0,
|
||||
validation_name="check-a",
|
||||
fix_description="test",
|
||||
success=False,
|
||||
)
|
||||
except PydanticValidationError as exc:
|
||||
context.frc_pydantic_error = str(exc)
|
||||
|
||||
|
||||
@when("a fix-revalidate FixAttemptRecord is created with whitespace validation_name")
|
||||
def step_when_create_record_whitespace_vname(context: Context) -> None:
|
||||
context.frc_pydantic_error = None
|
||||
try:
|
||||
FixAttemptRecord(
|
||||
attempt_number=1,
|
||||
validation_name=" ",
|
||||
fix_description="test",
|
||||
success=False,
|
||||
)
|
||||
except PydanticValidationError as exc:
|
||||
context.frc_pydantic_error = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps for new review-fix scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the mock event bus should have received {n:d} events")
|
||||
def step_then_mock_bus_event_count(context: Context, n: int) -> None:
|
||||
bus: _MockEventBus = context.frc_mock_event_bus
|
||||
assert len(bus.events) == n, (
|
||||
f"Expected {n} events, got {len(bus.events)}: "
|
||||
f"{[e.event_type for e in bus.events]}"
|
||||
)
|
||||
|
||||
|
||||
@then('mock event bus event {idx:d} should have event_type "{etype}"')
|
||||
def step_then_mock_bus_event_type(context: Context, idx: int, etype: str) -> None:
|
||||
bus: _MockEventBus = context.frc_mock_event_bus
|
||||
event = bus.events[idx - 1]
|
||||
assert event.event_type.value == etype, (
|
||||
f"Event {idx} type={event.event_type.value}, expected {etype}"
|
||||
)
|
||||
|
||||
|
||||
@then("a fix-revalidate pydantic validation error should be raised")
|
||||
def step_then_pydantic_error(context: Context) -> None:
|
||||
assert context.frc_pydantic_error is not None, (
|
||||
"Expected a Pydantic ValidationError but none was raised"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steps for B3, A1, R1, B2 review fixes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _long_description_fix(result: ValidationResult) -> str:
|
||||
"""Fix callback that returns a description exceeding 2000 chars."""
|
||||
return "x" * 3000
|
||||
|
||||
|
||||
class _MismatchedRevalidateCallback:
|
||||
"""Revalidate callback that returns a result with the wrong name."""
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name="wrong-validation-name",
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=True,
|
||||
message="passed but with wrong name",
|
||||
data=result.data,
|
||||
duration_ms=1.0,
|
||||
)
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage fix callback that returns a 3000 char description")
|
||||
def step_given_long_fix_callback(context: Context) -> None:
|
||||
context.frc_fix_callback = _long_description_fix
|
||||
|
||||
|
||||
@given(
|
||||
"a fix-revalidate coverage revalidate callback "
|
||||
"that returns a different validation_name"
|
||||
)
|
||||
def step_given_mismatched_revalidate(context: Context) -> None:
|
||||
context.frc_revalidate_callback = _MismatchedRevalidateCallback()
|
||||
|
||||
|
||||
@when(
|
||||
"a fix-revalidate FixThenRevalidateResult is created with "
|
||||
"escalated True and terminal_failure True"
|
||||
)
|
||||
def step_when_create_result_both_flags(context: Context) -> None:
|
||||
context.frc_pydantic_error = None
|
||||
try:
|
||||
FixThenRevalidateResult(
|
||||
validation_attempts=3,
|
||||
final_passed=False,
|
||||
escalated=True,
|
||||
terminal_failure=True,
|
||||
)
|
||||
except PydanticValidationError as exc:
|
||||
context.frc_pydantic_error = str(exc)
|
||||
|
||||
|
||||
@when(
|
||||
"a fix-revalidate FixAttemptRecord is created "
|
||||
"with validation_name exceeding 255 chars"
|
||||
)
|
||||
def step_when_create_record_long_vname(context: Context) -> None:
|
||||
context.frc_pydantic_error = None
|
||||
try:
|
||||
FixAttemptRecord(
|
||||
attempt_number=1,
|
||||
validation_name="v" * 256,
|
||||
fix_description="test",
|
||||
success=False,
|
||||
)
|
||||
except PydanticValidationError as exc:
|
||||
context.frc_pydantic_error = str(exc)
|
||||
|
||||
|
||||
@then(
|
||||
"fix-revalidate coverage validation fix history record {idx:d} "
|
||||
"fix_description should be at most {n:d} chars"
|
||||
)
|
||||
def step_then_record_fix_description_length(context: Context, idx: int, n: int) -> None:
|
||||
result: FixThenRevalidateResult = context.frc_result
|
||||
record = result.validation_fix_history[idx - 1]
|
||||
assert len(record.fix_description) <= n, (
|
||||
f"Record {idx} fix_description has {len(record.fix_description)} chars, "
|
||||
f"expected at most {n}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New steps for review-fix round 2/3 coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with boolean auto_strategy_revision")
|
||||
def step_when_create_bool_asr(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_asr: Any = True
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=3,
|
||||
auto_strategy_revision=bad_asr,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with auto_strategy_revision {val}")
|
||||
def step_when_create_bad_asr_range(context: Context, val: str) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=3,
|
||||
auto_strategy_revision=float(val),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with boolean auto_validation_fix")
|
||||
def step_when_create_bool_avf(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_avf: Any = True
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=3,
|
||||
auto_validation_fix=bad_avf,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with auto_validation_fix {val}")
|
||||
def step_when_create_bad_avf_range(context: Context, val: str) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=3,
|
||||
auto_validation_fix=float(val),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate orchestrator is created with invalid event_bus type")
|
||||
def step_when_create_bad_event_bus(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_bus: Any = 42
|
||||
FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=3,
|
||||
event_bus=bad_bus,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate run_fix_loop is called with non-callable fix_callback")
|
||||
def step_when_run_loop_non_callable_fix(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_fix: Any = "not_callable"
|
||||
context.frc_orchestrator.run_fix_loop(
|
||||
plan_id="P1",
|
||||
failed_results=[],
|
||||
fix_callback=bad_fix,
|
||||
revalidate_callback=_always_pass,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
@when("the fix-revalidate run_fix_loop is called with non-callable revalidate_callback")
|
||||
def step_when_run_loop_non_callable_revalidate(context: Context) -> None:
|
||||
context.frc_error = None
|
||||
try:
|
||||
bad_revalidate: Any = "not_callable"
|
||||
context.frc_orchestrator.run_fix_loop(
|
||||
plan_id="P1",
|
||||
failed_results=[],
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=bad_revalidate,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
context.frc_error = str(exc)
|
||||
|
||||
|
||||
def _none_fix(result: ValidationResult) -> None:
|
||||
"""Fix callback that returns None to signal unfixable."""
|
||||
return None
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage fix callback that returns None")
|
||||
def step_given_none_fix_callback(context: Context) -> None:
|
||||
context.frc_fix_callback = _none_fix
|
||||
|
||||
|
||||
class _PassedWithErrorRevalidateCallback:
|
||||
"""Revalidate callback that returns passed=True with an error set."""
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=True,
|
||||
message="passed but has error",
|
||||
data=result.data,
|
||||
duration_ms=1.0,
|
||||
error="runtime error in validation",
|
||||
)
|
||||
|
||||
|
||||
@given("a fix-revalidate coverage revalidate callback that returns passed with error")
|
||||
def step_given_passed_with_error_revalidate(context: Context) -> None:
|
||||
context.frc_revalidate_callback = _PassedWithErrorRevalidateCallback()
|
||||
|
||||
|
||||
@when(
|
||||
"a fix-revalidate FixThenRevalidateResult is created "
|
||||
"with final_passed True and escalated True"
|
||||
)
|
||||
def step_when_create_result_final_passed_escalated(context: Context) -> None:
|
||||
context.frc_pydantic_error = None
|
||||
try:
|
||||
FixThenRevalidateResult(
|
||||
validation_attempts=1,
|
||||
final_passed=True,
|
||||
escalated=True,
|
||||
)
|
||||
except PydanticValidationError as exc:
|
||||
context.frc_pydantic_error = str(exc)
|
||||
|
||||
|
||||
@then("the fix-revalidate auto_validation_fix property should be {val}")
|
||||
def step_then_avf_property(context: Context, val: str) -> None:
|
||||
expected = float(val)
|
||||
actual = context.frc_orchestrator.auto_validation_fix
|
||||
assert actual == expected, f"Expected auto_validation_fix={expected}, got {actual}"
|
||||
|
||||
|
||||
@given(
|
||||
"a fix-revalidate coverage orchestrator with max_retries {n:d} "
|
||||
"and mock event bus and escalation"
|
||||
)
|
||||
def step_given_coverage_orchestrator_mock_bus_escalation(
|
||||
context: Context, n: int
|
||||
) -> None:
|
||||
context.frc_mock_event_bus = _MockEventBus()
|
||||
context.frc_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=_make_pipeline(),
|
||||
max_retries=n,
|
||||
auto_strategy_revision=0.0,
|
||||
event_bus=context.frc_mock_event_bus,
|
||||
)
|
||||
context.frc_error = None
|
||||
context.frc_failed_results = []
|
||||
context.frc_fix_callback = _always_fix
|
||||
context.frc_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
@given('an informational coverage validation "{name}" that fails')
|
||||
def step_given_informational_coverage_validation(context: Context, name: str) -> None:
|
||||
context.frc_failed_results.append(
|
||||
_make_failed_result(name, ValidationMode.INFORMATIONAL)
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Step definitions for Fix-then-Revalidate orchestration scenarios.
|
||||
|
||||
All step names are prefixed with 'fix-revalidate' context to avoid
|
||||
AmbiguousStep collisions with existing step definitions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateOrchestrator,
|
||||
FixThenRevalidateResult,
|
||||
)
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_executor(validation_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Always-passing mock executor for pipeline construction."""
|
||||
return {"passed": True, "message": f"{validation_name} ok"}
|
||||
|
||||
|
||||
def _make_failed_result(
|
||||
name: str,
|
||||
mode: ValidationMode = ValidationMode.REQUIRED,
|
||||
) -> ValidationResult:
|
||||
"""Create a failed ValidationResult for testing."""
|
||||
return ValidationResult(
|
||||
validation_name=name,
|
||||
resource_id="RES0001",
|
||||
resource_name="test-resource",
|
||||
mode=mode,
|
||||
passed=False,
|
||||
message=f"{name} failed: expected condition not met",
|
||||
data={"hint": "check configuration"},
|
||||
duration_ms=10.0,
|
||||
)
|
||||
|
||||
|
||||
class _CountingRevalidateCallback:
|
||||
"""Revalidate callback that passes after a configurable number of fixes."""
|
||||
|
||||
def __init__(self, pass_after: int) -> None:
|
||||
self._pass_after = pass_after
|
||||
self._call_count = 0
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
self._call_count += 1
|
||||
passed = self._call_count >= self._pass_after
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=passed,
|
||||
message=f"{'passed' if passed else 'still failing'} "
|
||||
f"(attempt {self._call_count})",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
class _NeverPassRevalidateCallback:
|
||||
"""Revalidate callback that never passes."""
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=False,
|
||||
message="still failing",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
def _always_fix(result: ValidationResult) -> str:
|
||||
"""Fix callback that always returns a fix description."""
|
||||
return f"Applied fix for {result.validation_name}: adjusted configuration"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fix-revalidate orchestrator with max_retries {n:d}")
|
||||
def step_given_orchestrator(context: Context, n: int) -> None:
|
||||
pipeline = ValidationPipeline(
|
||||
commands=[],
|
||||
executor=_mock_executor,
|
||||
)
|
||||
context.fr_pipeline = pipeline
|
||||
context.fr_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=n,
|
||||
auto_strategy_revision=1.0,
|
||||
)
|
||||
context.fr_failed_results = [] # list[ValidationResult]
|
||||
context.fr_fix_callback = _always_fix
|
||||
context.fr_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
@given("a fix-revalidate orchestrator with auto_strategy_revision enabled")
|
||||
def step_given_orchestrator_auto_revision(context: Context) -> None:
|
||||
pipeline = ValidationPipeline(
|
||||
commands=[],
|
||||
executor=_mock_executor,
|
||||
)
|
||||
context.fr_pipeline = pipeline
|
||||
context.fr_orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
auto_strategy_revision=0.0,
|
||||
)
|
||||
context.fr_failed_results = []
|
||||
context.fr_fix_callback = _always_fix
|
||||
context.fr_revalidate_callback = _CountingRevalidateCallback(pass_after=1)
|
||||
|
||||
|
||||
@given('a required validation "{name}" that fails initially')
|
||||
def step_given_required_validation_fails(context: Context, name: str) -> None:
|
||||
result = _make_failed_result(name, ValidationMode.REQUIRED)
|
||||
context.fr_failed_results.append(result)
|
||||
|
||||
|
||||
@given('an informational validation "{name}" that fails')
|
||||
def step_given_informational_validation_fails(context: Context, name: str) -> None:
|
||||
result = _make_failed_result(name, ValidationMode.INFORMATIONAL)
|
||||
context.fr_failed_results.append(result)
|
||||
|
||||
|
||||
@given("a fix callback that always succeeds")
|
||||
def step_given_fix_callback(context: Context) -> None:
|
||||
context.fr_fix_callback = _always_fix
|
||||
|
||||
|
||||
@given("a revalidate callback that passes after {n:d} fix")
|
||||
def step_given_revalidate_passes_after_n(context: Context, n: int) -> None:
|
||||
context.fr_revalidate_callback = _CountingRevalidateCallback(pass_after=n)
|
||||
|
||||
|
||||
@given("a revalidate callback that passes after {n:d} fixes")
|
||||
def step_given_revalidate_passes_after_n_plural(context: Context, n: int) -> None:
|
||||
context.fr_revalidate_callback = _CountingRevalidateCallback(pass_after=n)
|
||||
|
||||
|
||||
@given("a revalidate callback that never passes")
|
||||
def step_given_revalidate_never_passes(context: Context) -> None:
|
||||
context.fr_revalidate_callback = _NeverPassRevalidateCallback()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('the fix-revalidate loop runs for plan "{plan_id}"')
|
||||
def step_when_run_fix_loop(context: Context, plan_id: str) -> None:
|
||||
orchestrator: FixThenRevalidateOrchestrator = context.fr_orchestrator
|
||||
result: FixThenRevalidateResult = orchestrator.run_fix_loop(
|
||||
plan_id=plan_id,
|
||||
failed_results=context.fr_failed_results,
|
||||
fix_callback=context.fr_fix_callback,
|
||||
revalidate_callback=context.fr_revalidate_callback,
|
||||
)
|
||||
context.fr_result = result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the fix-revalidate result should have validation_attempts {n:d}")
|
||||
def step_then_total_attempts(context: Context, n: int) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.validation_attempts == n, (
|
||||
f"Expected validation_attempts={n}, got {result.validation_attempts}"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate result should have final_passed {expected}")
|
||||
def step_then_final_passed(context: Context, expected: str) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
expected_bool = expected.strip().lower() == "true"
|
||||
assert result.final_passed is expected_bool, (
|
||||
f"Expected final_passed={expected_bool}, got {result.final_passed}"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate result should not be escalated")
|
||||
def step_then_not_escalated(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.escalated is False, "Expected escalated=False"
|
||||
|
||||
|
||||
@then("the fix-revalidate result should be escalated")
|
||||
def step_then_escalated(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.escalated is True, "Expected escalated=True"
|
||||
|
||||
|
||||
@then("the fix-revalidate result should not be terminal failure")
|
||||
def step_then_not_terminal(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.terminal_failure is False, "Expected terminal_failure=False"
|
||||
|
||||
|
||||
@then("the fix-revalidate result should be terminal failure")
|
||||
def step_then_terminal(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.terminal_failure is True, "Expected terminal_failure=True"
|
||||
|
||||
|
||||
@then("the validation fix history should have {n:d} records")
|
||||
def step_then_fix_history_count(context: Context, n: int) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert len(result.validation_fix_history) == n, (
|
||||
f"Expected {n} fix history records, got {len(result.validation_fix_history)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the fix-revalidate result should need user escalation")
|
||||
def step_then_needs_user_escalation(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.needs_user_escalation is True, "Expected needs_user_escalation=True"
|
||||
|
||||
|
||||
@then("the fix-revalidate result should not need user escalation")
|
||||
def step_then_not_needs_user_escalation(context: Context) -> None:
|
||||
result: FixThenRevalidateResult = context.fr_result
|
||||
assert result.needs_user_escalation is False, "Expected needs_user_escalation=False"
|
||||
@@ -0,0 +1,59 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for Fix-then-Revalidate orchestration loop
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_fix_then_revalidate.py
|
||||
|
||||
*** Test Cases ***
|
||||
Fix Then Revalidate Models Are Valid
|
||||
[Documentation] Verify FixAttemptRecord and FixThenRevalidateResult Pydantic models
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} models cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} models-ok
|
||||
|
||||
Fix Succeeds On First Attempt
|
||||
[Documentation] Fix loop resolves validation on first fix attempt
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} fix_first cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} fix-first-ok
|
||||
|
||||
Fix Succeeds After Multiple Attempts
|
||||
[Documentation] Fix loop resolves validation after multiple fix attempts
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} fix_multiple cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} fix-multiple-ok
|
||||
|
||||
Terminal Failure When All Attempts Exhausted
|
||||
[Documentation] Plan reaches terminal failure when retries exhausted
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} terminal_failure cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} terminal-failure-ok
|
||||
|
||||
Escalation To Strategy Revision
|
||||
[Documentation] Loop escalates to strategy revision when auto_strategy_revision enabled
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} escalation cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} escalation-ok
|
||||
|
||||
Informational Validations Skipped
|
||||
[Documentation] Informational validations do not trigger fix loop
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} informational_skipped cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} informational-skipped-ok
|
||||
|
||||
Retry Counting Per Validation Per Plan
|
||||
[Documentation] Verify retry counting and reset per validation per plan
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} retry_counting cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} retry-counting-ok
|
||||
|
||||
Unknown Command Returns Error
|
||||
[Documentation] Running with unknown command exits with code 1
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} nonexistent_cmd cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 1
|
||||
Should Contain ${result.stderr} Unknown command
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Helper script for Robot Framework fix-then-revalidate integration tests.
|
||||
|
||||
Self-contained Python helper that exercises the FixThenRevalidateOrchestrator
|
||||
and its models, printing sentinel strings on success and exiting
|
||||
with code 1 on failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixAttemptRecord,
|
||||
FixThenRevalidateOrchestrator,
|
||||
FixThenRevalidateResult,
|
||||
)
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
)
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_executor(validation_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"passed": True, "message": f"{validation_name} ok"}
|
||||
|
||||
|
||||
def _make_failed_result(
|
||||
name: str,
|
||||
mode: ValidationMode = ValidationMode.REQUIRED,
|
||||
) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=name,
|
||||
resource_id="RES0001",
|
||||
resource_name="test-resource",
|
||||
mode=mode,
|
||||
passed=False,
|
||||
message=f"{name} failed",
|
||||
data={"hint": "check config"},
|
||||
duration_ms=10.0,
|
||||
)
|
||||
|
||||
|
||||
def _always_fix(result: ValidationResult) -> str:
|
||||
return f"Fixed {result.validation_name}"
|
||||
|
||||
|
||||
class _CountingRevalidate:
|
||||
def __init__(self, pass_after: int) -> None:
|
||||
self._pass_after = pass_after
|
||||
self._count = 0
|
||||
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
self._count += 1
|
||||
passed = self._count >= self._pass_after
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=passed,
|
||||
message="passed" if passed else "still failing",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
class _NeverPassRevalidate:
|
||||
def __call__(self, result: ValidationResult) -> ValidationResult:
|
||||
return ValidationResult(
|
||||
validation_name=result.validation_name,
|
||||
resource_id=result.resource_id,
|
||||
resource_name=result.resource_name,
|
||||
mode=result.mode,
|
||||
passed=False,
|
||||
message="still failing",
|
||||
data=result.data,
|
||||
duration_ms=5.0,
|
||||
)
|
||||
|
||||
|
||||
def _check(condition: bool, message: str) -> None:
|
||||
"""Explicit check that works even with Python -O optimization."""
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_models() -> None:
|
||||
"""Verify FixAttemptRecord and FixThenRevalidateResult models."""
|
||||
record = FixAttemptRecord(
|
||||
attempt_number=1,
|
||||
validation_name="check-syntax",
|
||||
fix_description="Adjusted indentation",
|
||||
success=True,
|
||||
)
|
||||
_check(record.attempt_number == 1, "attempt_number should be 1")
|
||||
_check(record.success is True, "success should be True")
|
||||
|
||||
result = FixThenRevalidateResult(
|
||||
validation_attempts=2,
|
||||
final_passed=True,
|
||||
validation_fix_history=[record],
|
||||
escalated=False,
|
||||
terminal_failure=False,
|
||||
)
|
||||
_check(result.validation_attempts == 2, "validation_attempts should be 2")
|
||||
_check(result.final_passed is True, "final_passed should be True")
|
||||
_check(
|
||||
len(result.validation_fix_history) == 1,
|
||||
"validation_fix_history should have 1 record",
|
||||
)
|
||||
print("models-ok")
|
||||
|
||||
|
||||
def test_fix_succeeds_first_attempt() -> None:
|
||||
"""Fix succeeds on first attempt."""
|
||||
pipeline = ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
failed = [_make_failed_result("check-syntax")]
|
||||
result = orchestrator.run_fix_loop(
|
||||
plan_id="PLAN001",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=1),
|
||||
)
|
||||
_check(result.final_passed is True, "final_passed should be True")
|
||||
_check(result.validation_attempts == 1, "validation_attempts should be 1")
|
||||
_check(
|
||||
len(result.validation_fix_history) == 1,
|
||||
"validation_fix_history should have 1 record",
|
||||
)
|
||||
_check(
|
||||
result.validation_fix_history[0].success is True,
|
||||
"first fix history record should be success",
|
||||
)
|
||||
print("fix-first-ok")
|
||||
|
||||
|
||||
def test_fix_succeeds_after_multiple() -> None:
|
||||
"""Fix succeeds after multiple attempts."""
|
||||
pipeline = ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
failed = [_make_failed_result("check-lint")]
|
||||
result = orchestrator.run_fix_loop(
|
||||
plan_id="PLAN002",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=2),
|
||||
)
|
||||
_check(result.final_passed is True, "final_passed should be True")
|
||||
_check(result.validation_attempts == 2, "validation_attempts should be 2")
|
||||
print("fix-multiple-ok")
|
||||
|
||||
|
||||
def test_terminal_failure() -> None:
|
||||
"""Terminal failure when all attempts exhausted and no strategy revision."""
|
||||
pipeline = ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
auto_strategy_revision=1.0,
|
||||
)
|
||||
failed = [_make_failed_result("check-deploy")]
|
||||
result = orchestrator.run_fix_loop(
|
||||
plan_id="PLAN003",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_NeverPassRevalidate(),
|
||||
)
|
||||
_check(result.final_passed is False, "final_passed should be False")
|
||||
_check(result.needs_user_escalation is True, "needs_user_escalation should be True")
|
||||
_check(result.escalated is False, "escalated should be False")
|
||||
print("terminal-failure-ok")
|
||||
|
||||
|
||||
def test_escalation() -> None:
|
||||
"""Escalation to strategy revision when auto_strategy_revision is 0.0."""
|
||||
pipeline = ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
auto_strategy_revision=0.0,
|
||||
)
|
||||
failed = [_make_failed_result("check-security")]
|
||||
result = orchestrator.run_fix_loop(
|
||||
plan_id="PLAN004",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_NeverPassRevalidate(),
|
||||
)
|
||||
_check(result.final_passed is False, "final_passed should be False")
|
||||
_check(result.escalated is True, "escalated should be True")
|
||||
_check(result.terminal_failure is False, "terminal_failure should be False")
|
||||
print("escalation-ok")
|
||||
|
||||
|
||||
def test_informational_skipped() -> None:
|
||||
"""Informational validations are skipped by the fix loop."""
|
||||
pipeline = ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
failed = [_make_failed_result("check-style", ValidationMode.INFORMATIONAL)]
|
||||
result = orchestrator.run_fix_loop(
|
||||
plan_id="PLAN005",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=1),
|
||||
)
|
||||
_check(result.final_passed is True, "final_passed should be True")
|
||||
_check(result.validation_attempts == 0, "validation_attempts should be 0")
|
||||
_check(
|
||||
len(result.validation_fix_history) == 0,
|
||||
"validation_fix_history should be empty",
|
||||
)
|
||||
print("informational-skipped-ok")
|
||||
|
||||
|
||||
def test_retry_counting() -> None:
|
||||
"""Verify retry counting per validation per plan."""
|
||||
pipeline = ValidationPipeline(commands=[], executor=_mock_executor)
|
||||
orchestrator = FixThenRevalidateOrchestrator(
|
||||
validation_pipeline=pipeline,
|
||||
max_retries=3,
|
||||
)
|
||||
_check(
|
||||
orchestrator.get_retry_count("P1", "check-a") == 0,
|
||||
"initial retry count should be 0",
|
||||
)
|
||||
failed = [_make_failed_result("check-a")]
|
||||
orchestrator.run_fix_loop(
|
||||
plan_id="P1",
|
||||
failed_results=failed,
|
||||
fix_callback=_always_fix,
|
||||
revalidate_callback=_CountingRevalidate(pass_after=2),
|
||||
)
|
||||
_check(
|
||||
orchestrator.get_retry_count("P1", "check-a") == 0,
|
||||
"retry count should be 0 after run (auto-reset)",
|
||||
)
|
||||
orchestrator.reset_retry_counts("P1")
|
||||
_check(
|
||||
orchestrator.get_retry_count("P1", "check-a") == 0,
|
||||
"retry count should be 0 after explicit reset (no-op)",
|
||||
)
|
||||
print("retry-counting-ok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMMANDS = {
|
||||
"models": test_models,
|
||||
"fix_first": test_fix_succeeds_first_attempt,
|
||||
"fix_multiple": test_fix_succeeds_after_multiple,
|
||||
"terminal_failure": test_terminal_failure,
|
||||
"escalation": test_escalation,
|
||||
"informational_skipped": test_informational_skipped,
|
||||
"retry_counting": test_retry_counting,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_fix_then_revalidate.py <command>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
func = COMMANDS.get(cmd)
|
||||
if func is None:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
func()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -42,6 +42,9 @@ from cleveragents.application.services.decomposition_service import (
|
||||
from cleveragents.application.services.execution_environment_resolver import (
|
||||
ExecutionEnvironmentResolver,
|
||||
)
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateOrchestrator,
|
||||
)
|
||||
from cleveragents.application.services.multi_project_service import (
|
||||
MultiProjectService,
|
||||
)
|
||||
@@ -712,6 +715,13 @@ class Container(containers.DeclarativeContainer):
|
||||
ExecutionEnvironmentResolver,
|
||||
)
|
||||
|
||||
# Fix-then-Revalidate Orchestrator - Factory (per-plan lifecycle, #583)
|
||||
fix_then_revalidate_orchestrator = providers.Factory(
|
||||
FixThenRevalidateOrchestrator,
|
||||
validation_pipeline=None,
|
||||
event_bus=event_bus,
|
||||
)
|
||||
|
||||
# Plugin Manager - Singleton (shared plugin lifecycle management)
|
||||
plugin_manager = providers.Singleton(PluginManager)
|
||||
|
||||
|
||||
@@ -135,6 +135,15 @@ if TYPE_CHECKING:
|
||||
from cleveragents.application.services.execution_environment_resolver import (
|
||||
ExecutionEnvironmentResolver as ExecutionEnvironmentResolver,
|
||||
)
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixAttemptRecord as FixAttemptRecord,
|
||||
)
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateOrchestrator as FixThenRevalidateOrchestrator,
|
||||
)
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateResult as FixThenRevalidateResult,
|
||||
)
|
||||
from cleveragents.application.services.fusion_engine import (
|
||||
FusionConfig as FusionConfig,
|
||||
)
|
||||
@@ -440,6 +449,12 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"execution_environment_resolver",
|
||||
"ExecutionEnvironmentResolver",
|
||||
),
|
||||
"FixAttemptRecord": ("fix_then_revalidate", "FixAttemptRecord"),
|
||||
"FixThenRevalidateOrchestrator": (
|
||||
"fix_then_revalidate",
|
||||
"FixThenRevalidateOrchestrator",
|
||||
),
|
||||
"FixThenRevalidateResult": ("fix_then_revalidate", "FixThenRevalidateResult"),
|
||||
"FusionConfig": ("fusion_engine", "FusionConfig"),
|
||||
"FusionEngine": ("fusion_engine", "FusionEngine"),
|
||||
"FusionResult": ("fusion_engine", "FusionResult"),
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
"""Fix-then-Revalidate orchestration loop for required validations.
|
||||
|
||||
Implements the diagnosis -> self-fix -> re-validation -> retry limit ->
|
||||
strategy revision escalation -> user escalation -> terminal failure flow
|
||||
described in the specification section "Validation Failure Handling."
|
||||
|
||||
When a required validation fails during the Execute phase, this
|
||||
orchestrator drives a bounded retry loop:
|
||||
|
||||
1. **Diagnosis** -- the execution actor examines the validation's
|
||||
``message`` and ``data`` fields to understand the failure.
|
||||
2. **Self-fix attempt** -- the actor attempts to correct the issue
|
||||
within strategy constraints (via ``fix_callback``).
|
||||
3. **Re-validation** -- the failing validation is re-invoked (via
|
||||
``revalidate_callback``).
|
||||
4. **Retry limit** -- configurable via the ``max_retries`` constructor
|
||||
parameter (default 3, range 0--100 per spec Safety Profile
|
||||
``max_retries_per_step``).
|
||||
5. **Strategy revision** -- if the failure cannot be resolved and the
|
||||
``auto_strategy_revision`` confidence threshold is met, the result
|
||||
signals escalation so the caller can trigger a strategy revision.
|
||||
6. **User escalation** -- if strategy revision is not available, the
|
||||
result signals that user guidance is needed via
|
||||
``agents plan prompt``.
|
||||
7. **Terminal failure** -- reserved for the calling ``PlanExecutor``
|
||||
when user escalation also fails; the plan transitions to
|
||||
``state: failed`` and the sandbox is preserved for inspection.
|
||||
|
||||
Callback exceptions are caught and treated as failed attempts to ensure
|
||||
retry loop resilience. This prevents a single misbehaving callback from
|
||||
crashing the entire orchestration loop.
|
||||
|
||||
Validation errors (runtime exceptions during validation execution) are
|
||||
treated as required failures regardless of the validation's mode, per
|
||||
the specification section "Validation Error vs. Validation Failure."
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from cleveragents.application.services.validation_pipeline import (
|
||||
ValidationPipeline,
|
||||
ValidationResult,
|
||||
)
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.core.tool import ValidationMode
|
||||
from cleveragents.infrastructure.events.models import DomainEvent
|
||||
from cleveragents.infrastructure.events.protocol import EventBus
|
||||
from cleveragents.infrastructure.events.types import EventType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ULID pattern for plan_id validation (matches domain model convention)
|
||||
# ---------------------------------------------------------------------------
|
||||
_ULID_PATTERN = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Callback type aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FixCallback = Callable[[ValidationResult], str | None]
|
||||
"""Callable that attempts to fix a failed validation.
|
||||
|
||||
Receives the failed :class:`ValidationResult` and returns a
|
||||
human-readable description of the fix that was applied, or ``None``
|
||||
to signal that the failure is unfixable within current strategy
|
||||
constraints and the orchestrator should escalate immediately.
|
||||
|
||||
May raise exceptions, which are caught by the orchestrator and
|
||||
treated as failed fix attempts.
|
||||
"""
|
||||
|
||||
RevalidateCallback = Callable[[ValidationResult], ValidationResult]
|
||||
"""Callable that re-runs a single validation after a fix attempt.
|
||||
|
||||
Receives the original failed :class:`ValidationResult` (for context)
|
||||
and returns a fresh :class:`ValidationResult` from re-execution.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_VALIDATION_NAME_LEN = 255
|
||||
|
||||
|
||||
class FixAttemptRecord(BaseModel):
|
||||
"""Record of a single fix-then-revalidate attempt.
|
||||
|
||||
Attributes:
|
||||
timestamp: When the attempt was made.
|
||||
attempt_number: 1-based attempt counter for this validation.
|
||||
validation_name: Name of the validation that was retried.
|
||||
fix_description: Human-readable description of the fix applied.
|
||||
success: Whether re-validation passed after the fix.
|
||||
"""
|
||||
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=UTC),
|
||||
description="When the attempt was made (UTC)",
|
||||
)
|
||||
attempt_number: int = Field(..., ge=1, description="1-based attempt counter")
|
||||
validation_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=_MAX_VALIDATION_NAME_LEN,
|
||||
description="Validation that was retried",
|
||||
)
|
||||
fix_description: str = Field(
|
||||
..., max_length=2000, description="Description of the fix applied"
|
||||
)
|
||||
success: bool = Field(..., description="Whether re-validation passed after the fix")
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
|
||||
class FixThenRevalidateResult(BaseModel):
|
||||
"""Aggregated result from the fix-then-revalidate orchestration loop.
|
||||
|
||||
Attributes:
|
||||
validation_attempts: Total fix attempts across all validations.
|
||||
final_passed: Whether all required validations eventually passed.
|
||||
validation_fix_history: Ordered list of all fix attempt records.
|
||||
escalated: Whether the loop escalated to strategy revision.
|
||||
needs_user_escalation: Whether user guidance is needed.
|
||||
terminal_failure: Whether the plan reached terminal failure.
|
||||
validation_summary: Array of all intermediate validation results
|
||||
collected during the loop, including attempt numbers.
|
||||
final_validation_results: Snapshot of the final validation state
|
||||
for each processed validation.
|
||||
"""
|
||||
|
||||
validation_attempts: int = Field(
|
||||
..., ge=0, description="Total fix attempts across all validations"
|
||||
)
|
||||
final_passed: bool = Field(
|
||||
..., description="Whether all required validations eventually passed"
|
||||
)
|
||||
validation_fix_history: list[FixAttemptRecord] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered list of all fix attempt records",
|
||||
)
|
||||
escalated: bool = Field(
|
||||
default=False,
|
||||
description="Whether the loop escalated to strategy revision",
|
||||
)
|
||||
needs_user_escalation: bool = Field(
|
||||
default=False,
|
||||
description="Whether user guidance is needed via agents plan prompt",
|
||||
)
|
||||
terminal_failure: bool = Field(
|
||||
default=False,
|
||||
description="Whether the plan reached terminal failure",
|
||||
)
|
||||
validation_summary: list[ValidationResult] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Array of all intermediate validation results collected "
|
||||
"during the loop, including per-attempt outcomes"
|
||||
),
|
||||
)
|
||||
final_validation_results: list[ValidationResult] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Snapshot of the final validation state for each processed validation"
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_mutually_exclusive_flags(self) -> FixThenRevalidateResult:
|
||||
"""Enforce mutual exclusivity of outcome flags.
|
||||
|
||||
Invalid combinations:
|
||||
- At most one of ``escalated``, ``needs_user_escalation``, and
|
||||
``terminal_failure`` can be True.
|
||||
- ``final_passed`` True with any failure/escalation flag True.
|
||||
"""
|
||||
flags = [self.escalated, self.needs_user_escalation, self.terminal_failure]
|
||||
if sum(flags) > 1:
|
||||
msg = (
|
||||
"At most one of escalated, needs_user_escalation, "
|
||||
"and terminal_failure can be True"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if self.final_passed and any(flags):
|
||||
msg = (
|
||||
"final_passed cannot be True when escalated, "
|
||||
"needs_user_escalation, or terminal_failure is True"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_MAX_RETRIES = 3
|
||||
_MIN_MAX_RETRIES = 0
|
||||
_MAX_MAX_RETRIES = 100
|
||||
_MAX_FIX_DESCRIPTION_LEN = 2000
|
||||
_MAX_EVENT_BUS_CONSECUTIVE_FAILURES = 5
|
||||
|
||||
|
||||
def _sanitize_for_log(value: str) -> str:
|
||||
"""Remove control characters from a string for safe log output."""
|
||||
return value.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
|
||||
|
||||
class FixThenRevalidateOrchestrator:
|
||||
"""Orchestrates the fix-then-revalidate loop for failed validations.
|
||||
|
||||
Only processes **required** validations that have failed;
|
||||
informational failures are silently skipped. Validation errors
|
||||
(runtime exceptions) are treated as required failures regardless
|
||||
of the validation's mode.
|
||||
|
||||
Args:
|
||||
validation_pipeline: The ``ValidationPipeline`` instance used for
|
||||
context (not re-invoked directly -- use callbacks instead).
|
||||
max_retries: Maximum fix attempts per validation (default 3,
|
||||
range 0--100 per spec Safety Profile ``max_retries_per_step``).
|
||||
auto_strategy_revision: Confidence threshold (0.0--1.0) for
|
||||
automatic strategy revision when retries are exhausted.
|
||||
0.0 = always auto-escalate, 1.0 = always require manual
|
||||
approval.
|
||||
auto_validation_fix: Confidence threshold (0.0--1.0) for
|
||||
automatic validation fix attempts. 0.0 = always auto,
|
||||
1.0 = always manual. Stored for caller use.
|
||||
event_bus: Optional event bus for emitting domain events.
|
||||
|
||||
Raises:
|
||||
ValidationError: If *validation_pipeline* is ``None``,
|
||||
*max_retries* is out of range, or type constraints
|
||||
are violated.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
validation_pipeline: ValidationPipeline,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
auto_strategy_revision: float = 0.0,
|
||||
auto_validation_fix: float = 0.0,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> None:
|
||||
if validation_pipeline is None:
|
||||
raise ValidationError("validation_pipeline must not be None")
|
||||
if isinstance(max_retries, bool) or not isinstance(max_retries, int):
|
||||
raise ValidationError("max_retries must be an integer")
|
||||
if max_retries < _MIN_MAX_RETRIES or max_retries > _MAX_MAX_RETRIES:
|
||||
raise ValidationError(
|
||||
f"max_retries must be between {_MIN_MAX_RETRIES} and "
|
||||
f"{_MAX_MAX_RETRIES}, got {max_retries}"
|
||||
)
|
||||
if not isinstance(auto_strategy_revision, (int, float)) or isinstance(
|
||||
auto_strategy_revision, bool
|
||||
):
|
||||
raise ValidationError("auto_strategy_revision must be a float")
|
||||
if not (0.0 <= float(auto_strategy_revision) <= 1.0):
|
||||
raise ValidationError(
|
||||
"auto_strategy_revision must be between 0.0 and 1.0, "
|
||||
f"got {auto_strategy_revision}"
|
||||
)
|
||||
if not isinstance(auto_validation_fix, (int, float)) or isinstance(
|
||||
auto_validation_fix, bool
|
||||
):
|
||||
raise ValidationError("auto_validation_fix must be a float")
|
||||
if not (0.0 <= float(auto_validation_fix) <= 1.0):
|
||||
raise ValidationError(
|
||||
"auto_validation_fix must be between 0.0 and 1.0, "
|
||||
f"got {auto_validation_fix}"
|
||||
)
|
||||
if event_bus is not None and not isinstance(event_bus, EventBus):
|
||||
raise ValidationError("event_bus must be an EventBus instance or None")
|
||||
|
||||
self._pipeline = validation_pipeline
|
||||
self._max_retries = max_retries
|
||||
self._auto_strategy_revision = float(auto_strategy_revision)
|
||||
self._auto_validation_fix = float(auto_validation_fix)
|
||||
self._event_bus = event_bus
|
||||
# Per-(plan_id, validation_key) retry counters.
|
||||
# Inner key is (validation_name, resource_id) to prevent
|
||||
# same-named validations on different resources from sharing
|
||||
# a retry budget.
|
||||
self._retry_counts: dict[str, dict[tuple[str, str], int]] = defaultdict(
|
||||
lambda: defaultdict(int)
|
||||
)
|
||||
self._lock = threading.RLock()
|
||||
self._event_bus_consecutive_failures = 0
|
||||
|
||||
@property
|
||||
def max_retries(self) -> int:
|
||||
"""Maximum fix attempts per validation."""
|
||||
return self._max_retries
|
||||
|
||||
@property
|
||||
def auto_strategy_revision(self) -> float:
|
||||
"""Confidence threshold for automatic strategy revision."""
|
||||
return self._auto_strategy_revision
|
||||
|
||||
@property
|
||||
def auto_validation_fix(self) -> float:
|
||||
"""Confidence threshold for automatic validation fix attempts."""
|
||||
return self._auto_validation_fix
|
||||
|
||||
@property
|
||||
def event_bus(self) -> EventBus | None:
|
||||
"""Return the event bus, if configured."""
|
||||
return self._event_bus
|
||||
|
||||
def get_retry_count(
|
||||
self,
|
||||
plan_id: str,
|
||||
validation_name: str,
|
||||
resource_id: str = "",
|
||||
) -> int:
|
||||
"""Return the current fix attempt count for a validation in a plan.
|
||||
|
||||
Args:
|
||||
plan_id: The plan identifier.
|
||||
validation_name: The validation name.
|
||||
resource_id: The resource identifier (defaults to empty string
|
||||
for backward compatibility).
|
||||
|
||||
Returns:
|
||||
Number of fix attempts already made (0 if none).
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
if not validation_name:
|
||||
raise ValidationError("validation_name must not be empty")
|
||||
vkey = (validation_name, resource_id)
|
||||
with self._lock:
|
||||
plan_counts = self._retry_counts.get(plan_id)
|
||||
if plan_counts is None:
|
||||
return 0
|
||||
return plan_counts.get(vkey, 0)
|
||||
|
||||
def reset_retry_counts(self, plan_id: str) -> None:
|
||||
"""Reset all retry counters for a plan.
|
||||
|
||||
Args:
|
||||
plan_id: The plan identifier.
|
||||
|
||||
Raises:
|
||||
ValidationError: If *plan_id* is empty.
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
with self._lock:
|
||||
self._retry_counts.pop(plan_id, None)
|
||||
|
||||
def run_fix_loop(
|
||||
self,
|
||||
plan_id: str,
|
||||
failed_results: list[ValidationResult],
|
||||
fix_callback: FixCallback,
|
||||
revalidate_callback: RevalidateCallback,
|
||||
*,
|
||||
actor_name: str | None = None,
|
||||
) -> FixThenRevalidateResult:
|
||||
"""Run the fix-then-revalidate loop for failed validations.
|
||||
|
||||
Only **required** validations are processed. Informational
|
||||
failures are silently skipped unless they have a validation error,
|
||||
in which case they are treated as required failures per the
|
||||
specification section "Validation Error vs. Validation Failure."
|
||||
|
||||
Args:
|
||||
plan_id: Identifier of the plan being executed (ULID format).
|
||||
failed_results: List of failed validation results to process.
|
||||
fix_callback: Callable that attempts to fix a failure.
|
||||
revalidate_callback: Callable that re-runs a validation.
|
||||
actor_name: Optional actor name for event correlation.
|
||||
|
||||
Returns:
|
||||
:class:`FixThenRevalidateResult` describing the loop outcome.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required arguments are invalid.
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValidationError("plan_id must not be empty")
|
||||
if failed_results is None:
|
||||
raise ValidationError("failed_results must not be None")
|
||||
if fix_callback is None:
|
||||
raise ValidationError("fix_callback must not be None")
|
||||
if not callable(fix_callback):
|
||||
raise ValidationError("fix_callback must be callable")
|
||||
if revalidate_callback is None:
|
||||
raise ValidationError("revalidate_callback must not be None")
|
||||
if not callable(revalidate_callback):
|
||||
raise ValidationError("revalidate_callback must be callable")
|
||||
|
||||
# Filter to required failures AND validation errors (regardless
|
||||
# of mode). Per spec: validation errors are "always treated as
|
||||
# required failure regardless of mode."
|
||||
required_failures = [
|
||||
r
|
||||
for r in failed_results
|
||||
if (r.mode == ValidationMode.REQUIRED and not r.passed)
|
||||
or (r.error is not None)
|
||||
]
|
||||
|
||||
if not required_failures:
|
||||
log = logger.bind(plan_id=plan_id)
|
||||
log.info("no_required_validation_failures_to_fix")
|
||||
return FixThenRevalidateResult(
|
||||
validation_attempts=0,
|
||||
final_passed=True,
|
||||
validation_fix_history=[],
|
||||
escalated=False,
|
||||
needs_user_escalation=False,
|
||||
terminal_failure=False,
|
||||
validation_summary=[],
|
||||
final_validation_results=[],
|
||||
)
|
||||
|
||||
validation_fix_history: list[FixAttemptRecord] = []
|
||||
validation_summary: list[ValidationResult] = []
|
||||
final_validation_results: list[ValidationResult] = []
|
||||
all_resolved = True
|
||||
|
||||
for failed_result in required_failures:
|
||||
resolved, last_result, intermediate_results = self._fix_single_validation(
|
||||
plan_id=plan_id,
|
||||
failed_result=failed_result,
|
||||
fix_callback=fix_callback,
|
||||
revalidate_callback=revalidate_callback,
|
||||
validation_fix_history=validation_fix_history,
|
||||
actor_name=actor_name,
|
||||
)
|
||||
validation_summary.extend(intermediate_results)
|
||||
final_validation_results.append(last_result)
|
||||
if not resolved:
|
||||
all_resolved = False
|
||||
|
||||
validation_attempts = len(validation_fix_history)
|
||||
|
||||
# Auto-reset retry counts for this plan after the loop completes
|
||||
# to prevent cross-invocation budget exhaustion when the caller
|
||||
# re-invokes after strategy revision.
|
||||
self.reset_retry_counts(plan_id)
|
||||
|
||||
if all_resolved:
|
||||
log = logger.bind(plan_id=plan_id, validation_attempts=validation_attempts)
|
||||
log.info("all_required_validations_resolved")
|
||||
return FixThenRevalidateResult(
|
||||
validation_attempts=validation_attempts,
|
||||
final_passed=True,
|
||||
validation_fix_history=validation_fix_history,
|
||||
escalated=False,
|
||||
needs_user_escalation=False,
|
||||
terminal_failure=False,
|
||||
validation_summary=validation_summary,
|
||||
final_validation_results=final_validation_results,
|
||||
)
|
||||
|
||||
# --- Escalation path: strategy revision ---
|
||||
if self._auto_strategy_revision < 1.0:
|
||||
log = logger.bind(plan_id=plan_id)
|
||||
log.warning("fix_loop_exhausted_escalating_to_strategy_revision")
|
||||
self._emit_event(
|
||||
EventType.VALIDATION_FIX_EXHAUSTED,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details={
|
||||
"validation_attempts": validation_attempts,
|
||||
"escalated": True,
|
||||
"needs_user_escalation": False,
|
||||
"terminal_failure": False,
|
||||
},
|
||||
)
|
||||
return FixThenRevalidateResult(
|
||||
validation_attempts=validation_attempts,
|
||||
final_passed=False,
|
||||
validation_fix_history=validation_fix_history,
|
||||
escalated=True,
|
||||
needs_user_escalation=False,
|
||||
terminal_failure=False,
|
||||
validation_summary=validation_summary,
|
||||
final_validation_results=final_validation_results,
|
||||
)
|
||||
|
||||
# --- User escalation (strategy revision not available) ---
|
||||
log = logger.bind(plan_id=plan_id)
|
||||
log.warning("fix_loop_exhausted_needs_user_escalation")
|
||||
self._emit_event(
|
||||
EventType.VALIDATION_FIX_EXHAUSTED,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details={
|
||||
"validation_attempts": validation_attempts,
|
||||
"escalated": False,
|
||||
"needs_user_escalation": True,
|
||||
"terminal_failure": False,
|
||||
},
|
||||
)
|
||||
return FixThenRevalidateResult(
|
||||
validation_attempts=validation_attempts,
|
||||
final_passed=False,
|
||||
validation_fix_history=validation_fix_history,
|
||||
escalated=False,
|
||||
needs_user_escalation=True,
|
||||
terminal_failure=False,
|
||||
validation_summary=validation_summary,
|
||||
final_validation_results=final_validation_results,
|
||||
)
|
||||
|
||||
def _fix_single_validation(
|
||||
self,
|
||||
plan_id: str,
|
||||
failed_result: ValidationResult,
|
||||
fix_callback: FixCallback,
|
||||
revalidate_callback: RevalidateCallback,
|
||||
validation_fix_history: list[FixAttemptRecord],
|
||||
actor_name: str | None = None,
|
||||
) -> tuple[bool, ValidationResult, list[ValidationResult]]:
|
||||
"""Attempt to fix and revalidate a single failed validation.
|
||||
|
||||
Callback exceptions are caught and treated as failed attempts
|
||||
to ensure retry loop resilience.
|
||||
|
||||
Args:
|
||||
plan_id: The plan identifier.
|
||||
failed_result: The validation result that failed.
|
||||
fix_callback: Callback to attempt a fix.
|
||||
revalidate_callback: Callback to re-run the validation.
|
||||
validation_fix_history: Mutable list to append records to.
|
||||
actor_name: Optional actor name for event correlation.
|
||||
|
||||
Returns:
|
||||
Tuple of (resolved, last_result, intermediate_results) where
|
||||
resolved is ``True`` if the validation was fixed, last_result
|
||||
is the final ``ValidationResult`` for this validation, and
|
||||
intermediate_results is a list of all ``ValidationResult``
|
||||
objects produced during the attempts (for validation_summary).
|
||||
"""
|
||||
vname = failed_result.validation_name
|
||||
safe_vname = _sanitize_for_log(vname)
|
||||
resource_id = failed_result.resource_id
|
||||
# Defensive truncation to prevent FixAttemptRecord Pydantic error
|
||||
# when ValidationResult.validation_name exceeds max_length.
|
||||
truncated_vname = vname[:_MAX_VALIDATION_NAME_LEN]
|
||||
vkey = (vname, resource_id)
|
||||
attempted = False
|
||||
last_result = failed_result
|
||||
intermediate_results: list[ValidationResult] = []
|
||||
|
||||
for _ in range(self._max_retries):
|
||||
# Atomically claim a retry slot. This prevents the TOCTOU
|
||||
# race where concurrent threads could both read the same
|
||||
# counter value, pass the budget guard, and exceed the
|
||||
# configured retry limit.
|
||||
with self._lock:
|
||||
plan_counts = self._retry_counts.get(plan_id)
|
||||
current = plan_counts.get(vkey, 0) if plan_counts is not None else 0
|
||||
if current >= self._max_retries:
|
||||
break
|
||||
attempt_num = current + 1
|
||||
self._retry_counts[plan_id][vkey] = attempt_num
|
||||
|
||||
attempted = True
|
||||
|
||||
log = logger.bind(
|
||||
plan_id=plan_id,
|
||||
validation_name=safe_vname,
|
||||
attempt=attempt_num,
|
||||
max_retries=self._max_retries,
|
||||
)
|
||||
log.info("fix_attempt_started")
|
||||
|
||||
# Step 1: Diagnosis + fix attempt.
|
||||
# Exceptions from the callback are treated as failed fix
|
||||
# attempts to ensure loop resilience.
|
||||
try:
|
||||
fix_description = fix_callback(failed_result)
|
||||
except Exception as exc:
|
||||
fix_description = (
|
||||
f"Fix callback raised {type(exc).__name__} for "
|
||||
f"'{safe_vname}' (attempt {attempt_num})"
|
||||
)
|
||||
log.warning(
|
||||
"fix_callback_exception",
|
||||
error_type=type(exc).__name__,
|
||||
error_msg=str(exc)[:200],
|
||||
)
|
||||
record = FixAttemptRecord(
|
||||
attempt_number=attempt_num,
|
||||
validation_name=truncated_vname,
|
||||
fix_description=fix_description[:_MAX_FIX_DESCRIPTION_LEN],
|
||||
success=False,
|
||||
)
|
||||
validation_fix_history.append(record)
|
||||
self._emit_event(
|
||||
EventType.VALIDATION_FIX_ATTEMPTED,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details={
|
||||
"validation_name": vname,
|
||||
"attempt_number": attempt_num,
|
||||
"success": False,
|
||||
"error": "fix_callback_exception",
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle early-exit signal: None means "unfixable, escalate"
|
||||
if fix_description is None:
|
||||
log.info("fix_callback_signalled_unfixable")
|
||||
record = FixAttemptRecord(
|
||||
attempt_number=attempt_num,
|
||||
validation_name=truncated_vname,
|
||||
fix_description="Callback signalled unfixable",
|
||||
success=False,
|
||||
)
|
||||
validation_fix_history.append(record)
|
||||
self._emit_event(
|
||||
EventType.VALIDATION_FIX_ATTEMPTED,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details={
|
||||
"validation_name": vname,
|
||||
"attempt_number": attempt_num,
|
||||
"success": False,
|
||||
"error": "unfixable_within_strategy",
|
||||
},
|
||||
)
|
||||
return False, last_result, intermediate_results
|
||||
|
||||
# Defensive type coercion for non-string returns
|
||||
if not isinstance(fix_description, str):
|
||||
fix_description = str(fix_description)
|
||||
|
||||
# Truncate fix_description to prevent PydanticValidationError
|
||||
# from the max_length constraint on FixAttemptRecord.
|
||||
fix_description = fix_description[:_MAX_FIX_DESCRIPTION_LEN]
|
||||
|
||||
# Step 2: Re-validation.
|
||||
# Exceptions are treated as validation errors (passed=False).
|
||||
try:
|
||||
new_result = revalidate_callback(failed_result)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"revalidate_callback_exception",
|
||||
error_type=type(exc).__name__,
|
||||
error_msg=str(exc)[:200],
|
||||
)
|
||||
new_result = ValidationResult(
|
||||
validation_name=failed_result.validation_name,
|
||||
resource_id=failed_result.resource_id,
|
||||
resource_name=failed_result.resource_name,
|
||||
mode=failed_result.mode,
|
||||
passed=False,
|
||||
message=(
|
||||
f"Revalidation raised an exception (attempt {attempt_num})"
|
||||
),
|
||||
data=failed_result.data,
|
||||
duration_ms=0.0,
|
||||
error=(f"revalidate_callback exception (attempt {attempt_num})"),
|
||||
)
|
||||
|
||||
# Verify the returned result matches the expected validation.
|
||||
if new_result.validation_name != vname:
|
||||
log.warning(
|
||||
"revalidate_validation_name_mismatch",
|
||||
expected=safe_vname,
|
||||
got=_sanitize_for_log(new_result.validation_name),
|
||||
)
|
||||
new_result = ValidationResult(
|
||||
validation_name=vname,
|
||||
resource_id=failed_result.resource_id,
|
||||
resource_name=failed_result.resource_name,
|
||||
mode=failed_result.mode,
|
||||
passed=False,
|
||||
message=(
|
||||
f"Revalidation returned mismatched "
|
||||
f"validation_name (expected '{safe_vname}', "
|
||||
f"got '{_sanitize_for_log(new_result.validation_name)}')"
|
||||
),
|
||||
data=failed_result.data,
|
||||
duration_ms=new_result.duration_ms,
|
||||
error=(f"validation_name mismatch (attempt {attempt_num})"),
|
||||
)
|
||||
|
||||
# Handle contradictory passed=True with error set.
|
||||
# Per spec: validation errors are always treated as required
|
||||
# failure regardless of mode.
|
||||
if new_result.passed and new_result.error is not None:
|
||||
log.warning(
|
||||
"contradictory_passed_with_error",
|
||||
error=new_result.error[:200],
|
||||
)
|
||||
new_result = ValidationResult(
|
||||
validation_name=new_result.validation_name,
|
||||
resource_id=new_result.resource_id,
|
||||
resource_name=new_result.resource_name,
|
||||
mode=new_result.mode,
|
||||
passed=False,
|
||||
message=(
|
||||
f"Validation reported passed=True with error "
|
||||
f"set; treating as failed: {new_result.error}"
|
||||
),
|
||||
data=new_result.data,
|
||||
duration_ms=new_result.duration_ms,
|
||||
error=new_result.error,
|
||||
)
|
||||
|
||||
last_result = new_result
|
||||
intermediate_results.append(new_result)
|
||||
|
||||
record = FixAttemptRecord(
|
||||
attempt_number=attempt_num,
|
||||
validation_name=truncated_vname,
|
||||
fix_description=fix_description,
|
||||
success=new_result.passed,
|
||||
)
|
||||
validation_fix_history.append(record)
|
||||
|
||||
self._emit_event(
|
||||
EventType.VALIDATION_FIX_ATTEMPTED,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details={
|
||||
"validation_name": vname,
|
||||
"attempt_number": attempt_num,
|
||||
"success": new_result.passed,
|
||||
},
|
||||
)
|
||||
|
||||
if new_result.passed:
|
||||
log.info("validation_passed_after_fix")
|
||||
self._emit_event(
|
||||
EventType.VALIDATION_FIX_SUCCEEDED,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details={
|
||||
"validation_name": vname,
|
||||
"attempt_number": attempt_num,
|
||||
},
|
||||
)
|
||||
return True, last_result, intermediate_results
|
||||
|
||||
# Update failed_result for next iteration's diagnosis
|
||||
failed_result = new_result
|
||||
|
||||
if not attempted:
|
||||
log = logger.bind(
|
||||
plan_id=plan_id,
|
||||
validation_name=safe_vname,
|
||||
current_count=self.get_retry_count(plan_id, vname, resource_id),
|
||||
max_retries=self._max_retries,
|
||||
)
|
||||
log.warning("retry_budget_already_exhausted")
|
||||
else:
|
||||
log = logger.bind(
|
||||
plan_id=plan_id,
|
||||
validation_name=safe_vname,
|
||||
max_retries=self._max_retries,
|
||||
)
|
||||
log.warning("retry_limit_exhausted")
|
||||
return False, last_result, intermediate_results
|
||||
|
||||
def _emit_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
plan_id: str,
|
||||
details: dict[str, object],
|
||||
actor_name: str | None = None,
|
||||
) -> None:
|
||||
"""Emit a domain event if an event bus is configured.
|
||||
|
||||
Includes a simple circuit breaker: after
|
||||
``_MAX_EVENT_BUS_CONSECUTIVE_FAILURES`` consecutive failures,
|
||||
event emission is silently disabled until a successful emit
|
||||
resets the counter.
|
||||
|
||||
Args:
|
||||
event_type: The event type to emit.
|
||||
plan_id: The plan identifier for correlation.
|
||||
details: Event-specific payload.
|
||||
actor_name: Optional actor name for event correlation.
|
||||
"""
|
||||
if self._event_bus is None:
|
||||
return
|
||||
with self._lock:
|
||||
if (
|
||||
self._event_bus_consecutive_failures
|
||||
>= _MAX_EVENT_BUS_CONSECUTIVE_FAILURES
|
||||
):
|
||||
return
|
||||
try:
|
||||
self._event_bus.emit(
|
||||
DomainEvent(
|
||||
event_type=event_type,
|
||||
plan_id=plan_id,
|
||||
actor_name=actor_name,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
with self._lock:
|
||||
self._event_bus_consecutive_failures = 0
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._event_bus_consecutive_failures += 1
|
||||
consecutive_failures = self._event_bus_consecutive_failures
|
||||
log = logger.bind(
|
||||
event_type=str(event_type),
|
||||
plan_id=plan_id,
|
||||
consecutive_failures=consecutive_failures,
|
||||
)
|
||||
log.warning("event_bus_emit_failed")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FixAttemptRecord",
|
||||
"FixCallback",
|
||||
"FixThenRevalidateOrchestrator",
|
||||
"FixThenRevalidateResult",
|
||||
"RevalidateCallback",
|
||||
]
|
||||
@@ -23,6 +23,9 @@ from ulid import ULID
|
||||
from cleveragents.application.services.autonomy_guardrail_service import (
|
||||
AutonomyGuardrailService,
|
||||
)
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateOrchestrator,
|
||||
)
|
||||
from cleveragents.application.services.plan_execution_context import (
|
||||
PlanExecutionContext,
|
||||
RuntimeExecuteActor,
|
||||
@@ -299,6 +302,7 @@ class PlanExecutor:
|
||||
metrics_emitter: MetricsEmitter | None = None,
|
||||
strategize_actor: Any | None = None,
|
||||
execute_actor: Any | None = None,
|
||||
fix_revalidate_orchestrator: FixThenRevalidateOrchestrator | None = None,
|
||||
) -> None:
|
||||
"""Initialize the plan executor.
|
||||
|
||||
@@ -322,6 +326,9 @@ class PlanExecutor:
|
||||
execute_actor: Optional custom execute actor. When ``None``,
|
||||
the default ``ExecuteStubActor`` is used. Pass an
|
||||
``LLMExecuteActor`` for real LLM execution.
|
||||
fix_revalidate_orchestrator: Optional orchestrator for
|
||||
fix-then-revalidate loops when required validations
|
||||
fail during execution (Forgejo #583).
|
||||
"""
|
||||
if lifecycle_service is None:
|
||||
raise ValidationError("lifecycle_service must not be None")
|
||||
@@ -333,6 +340,7 @@ class PlanExecutor:
|
||||
self._checkpoint_manager = checkpoint_manager
|
||||
self._guardrail_service = guardrail_service
|
||||
self._metrics_emitter = metrics_emitter
|
||||
self._fix_revalidate_orchestrator = fix_revalidate_orchestrator
|
||||
self._strategize_actor = strategize_actor or StrategizeStubActor()
|
||||
self._execute_actor = execute_actor or ExecuteStubActor()
|
||||
self._logger = logger.bind(service="plan_executor")
|
||||
@@ -382,6 +390,13 @@ class PlanExecutor:
|
||||
"""Return the execution context, if configured."""
|
||||
return self._execution_context
|
||||
|
||||
@property
|
||||
def fix_revalidate_orchestrator(
|
||||
self,
|
||||
) -> FixThenRevalidateOrchestrator | None:
|
||||
"""Return the fix-then-revalidate orchestrator, if configured."""
|
||||
return self._fix_revalidate_orchestrator
|
||||
|
||||
def _try_create_checkpoint(
|
||||
self,
|
||||
plan_id: str,
|
||||
|
||||
@@ -86,6 +86,9 @@ class EventType(StrEnum):
|
||||
VALIDATION_STARTED = "validation.started"
|
||||
VALIDATION_PASSED = "validation.passed"
|
||||
VALIDATION_FAILED = "validation.failed"
|
||||
VALIDATION_FIX_ATTEMPTED = "validation.fix_attempted"
|
||||
VALIDATION_FIX_SUCCEEDED = "validation.fix_succeeded"
|
||||
VALIDATION_FIX_EXHAUSTED = "validation.fix_exhausted"
|
||||
|
||||
# --- Session ---
|
||||
SESSION_CREATED = "session.created"
|
||||
|
||||
Reference in New Issue
Block a user