diff --git a/docs/reference/subplan_service.md b/docs/reference/subplan_service.md index 034d48887..c66858b83 100644 --- a/docs/reference/subplan_service.md +++ b/docs/reference/subplan_service.md @@ -89,7 +89,7 @@ Before any child plan is created, `validate_spawn` checks: 5. **Correct decision types**: Each entry's decision must be either `subplan_spawn` or `subplan_parallel_spawn`. -If any check fails, `SpawnValidationError` is raised with all errors. +If any check fails, `SubplanSpawnError` is raised with a message listing all errors. ## Integration with Other Services @@ -129,12 +129,13 @@ parent_plan.subplan_statuses = result.spawned_statuses ## Error Handling -| Error | When | -|--------------------------|---------------------------------------------| -| `ValueError` | `None` or empty required arguments | -| `SpawnValidationError` | Validation checks fail (resource scope, | -| | merge strategy, max_parallel, action name, | -| | or decision type) | +| Error | When | +|------------------------------|---------------------------------------------| +| `ValueError` | `None` or empty required arguments | +| `SubplanSpawnError` (v3.3.0) | Validation checks fail (resource scope, | +| | merge strategy, max_parallel, action name, | +| | or decision type) | -`SpawnValidationError` inherits from `ValidationError` and includes a -`validation_errors` list with detailed messages for each failure. +`SubplanSpawnError` inherits from `PlanError` and carries a descriptive message +listing all validation failures. The error is centralized in +`cleveragents.core.exceptions` (moved from the local subplan_service module in v3.3.0). diff --git a/features/services_init_coverage_r3.feature b/features/services_init_coverage_r3.feature index 7f5fdc000..060def464 100644 --- a/features/services_init_coverage_r3.feature +++ b/features/services_init_coverage_r3.feature @@ -525,10 +525,10 @@ Feature: Services __init__ lazy-import coverage (round 3) When svcov3 I access lazy attribute "SpawnResult" Then svcov3 the attribute "SpawnResult" should be resolved - Scenario: svcov3 lazy-load SpawnValidationError + Scenario: svcov3 lazy-load SubplanSpawnError Given svcov3 a fresh services module - When svcov3 I access lazy attribute "SpawnValidationError" - Then svcov3 the attribute "SpawnValidationError" should be resolved + When svcov3 I access lazy attribute "SubplanSpawnError" + Then svcov3 the attribute "SubplanSpawnError" should be resolved Scenario: svcov3 lazy-load SpawnValidationResult Given svcov3 a fresh services module diff --git a/features/steps/subplan_service_coverage_boost_steps.py b/features/steps/subplan_service_coverage_boost_steps.py index 37c31f457..aa6e64bc2 100644 --- a/features/steps/subplan_service_coverage_boost_steps.py +++ b/features/steps/subplan_service_coverage_boost_steps.py @@ -1,11 +1,11 @@ """Step definitions for subplan service coverage boost scenarios. Targets uncovered lines in subplan_service.py: - - Lines 129-131: SpawnValidationError.__init__ + - Lines 32-35: SubplanSpawnError import from core.exceptions (v3.3.0) - Line 167: decision_service property - Line 207: spawn() config=None guard - Line 209: spawn() spawn_entries=None guard - - Line 220: spawn() raising SpawnValidationError + - Line 215: spawn() raising SubplanSpawnError - Line 301: validate_spawn() config=None guard - Line 303: validate_spawn() spawn_entries=None guard - Lines 334-336: empty action_name validation @@ -22,10 +22,11 @@ from behave.runner import Context from cleveragents.application.services.subplan_service import ( SpawnEntry, - SpawnValidationError, SpawnValidationResult, SubplanService, ) + +from cleveragents.core.exceptions import SubplanSpawnError from cleveragents.domain.models.core.decision import ( ContextSnapshot, Decision, @@ -156,11 +157,13 @@ def step_given_entry_whitespace_action(context: Context) -> None: # --------------------------------------------------------------------------- -@when('a SpawnValidationError is created with errors "{err1}" and "{err2}"') -def step_when_create_spawn_validation_error( +@when('a SubplanSpawnError is created for errors "{err1}" and "{err2}"') +def step_when_create_subplan_spawn_error( context: Context, err1: str, err2: str ) -> None: - context.cov_sve = SpawnValidationError([err1, err2]) + # SubplanSpawnError (v3.3.0) takes a message string instead of list + msg = f"Spawn validation failed: {err1}; {err2}" + context.cov_sve = SubplanSpawnError(msg) @when("the decision_service property is accessed") @@ -205,7 +208,7 @@ def step_when_spawn_validation_fail(context: Context) -> None: config=context.cov_config, spawn_entries=context.cov_spawn_entries, ) - except SpawnValidationError as exc: + except SubplanSpawnError as exc: context.cov_error = exc @@ -273,14 +276,16 @@ def step_when_build_entries_none(context: Context) -> None: # --------------------------------------------------------------------------- -@then("the SpawnValidationError validation_errors list should have {n:d} entries") +@then("the SubplanSpawnError message should contain {n:d} semicolons") def step_then_sve_count(context: Context, n: int) -> None: - assert len(context.cov_sve.validation_errors) == n, ( - f"Expected {n} errors, got {len(context.cov_sve.validation_errors)}" + # With v3.3.0 API, errors are joined into a single message string + msg = str(context.cov_sve) + assert msg.count(";") == n, ( + f"Expected {n} semicolons in message, got {msg.count(';')}" ) -@then('the SpawnValidationError message should contain "{text}"') +@then('the SubplanSpawnError message should contain "{text}"') def step_then_sve_message(context: Context, text: str) -> None: msg = str(context.cov_sve) assert text in msg, f"Expected '{text}' in message: {msg}" @@ -304,17 +309,17 @@ def step_then_value_error_text(context: Context, text: str) -> None: assert text in str(context.cov_error), f"Expected '{text}' in '{context.cov_error}'" -@then("a SpawnValidationError should be raised") +@then("a SubplanSpawnError should be raised") def step_then_sve_raised(context: Context) -> None: assert context.cov_error is not None, ( - "Expected SpawnValidationError but no error was raised" + "Expected SubplanSpawnError but no error was raised" ) - assert isinstance(context.cov_error, SpawnValidationError), ( - f"Expected SpawnValidationError, got {type(context.cov_error).__name__}" + assert isinstance(context.cov_error, SubplanSpawnError), ( + f"Expected SubplanSpawnError, got {type(context.cov_error).__name__}" ) -@then('the SpawnValidationError should mention "{text}"') +@then('the SubplanSpawnError should mention "{text}"') def step_then_sve_mentions(context: Context, text: str) -> None: msg = str(context.cov_error) assert text.lower() in msg.lower(), f"Expected '{text}' in error message: {msg}" diff --git a/features/steps/subplan_spawn_service_steps.py b/features/steps/subplan_spawn_service_steps.py index 456f69d02..0455310f9 100644 --- a/features/steps/subplan_spawn_service_steps.py +++ b/features/steps/subplan_spawn_service_steps.py @@ -9,9 +9,10 @@ from behave.runner import Context from cleveragents.application.services.subplan_service import ( SpawnEntry, - SpawnValidationError, SubplanService, ) + +from cleveragents.core.exceptions import SubplanSpawnError from cleveragents.domain.models.core.decision import ( ContextSnapshot, Decision, @@ -118,7 +119,7 @@ def step_when_spawn(context: Context) -> None: spawn_entries=context.spawn_entries, available_resources=getattr(context, "available_resources", None), ) - except (SpawnValidationError, ValueError) as exc: + except (SubplanSpawnError, ValueError) as exc: context.spawn_error = exc context.spawn_result = None diff --git a/features/subplan_service_coverage_boost.feature b/features/subplan_service_coverage_boost.feature index 7d2b364f3..f7e021785 100644 --- a/features/subplan_service_coverage_boost.feature +++ b/features/subplan_service_coverage_boost.feature @@ -1,16 +1,17 @@ @phase1 @subplan @coverage Feature: Subplan Service Coverage Boost Additional scenarios targeting uncovered code paths in - SubplanService, SpawnValidationError, and related helpers. + SubplanService, SubplanSpawnError, and related helpers. - # --- SpawnValidationError direct instantiation (lines 129-131) --- + # --- SubplanSpawnError direct instantiation (v3.3.0) --- @error_object - Scenario: SpawnValidationError stores validation errors and formats message - When a SpawnValidationError is created with errors "bad resource" and "missing merge" - Then the SpawnValidationError validation_errors list should have 2 entries - And the SpawnValidationError message should contain "bad resource" - And the SpawnValidationError message should contain "missing merge" + Scenario: SubplanSpawnError message format includes errors + When a SubplanSpawnError is created for errors "bad resource" and "missing merge" + Then the SubplanSpawnError message should contain {1:d} semicolons + And the SubplanSpawnError message should contain "Spawn validation failed" + And the SubplanSpawnError message should contain "bad resource" + And the SubplanSpawnError message should contain "missing merge" # --- decision_service property (line 167) --- @@ -39,17 +40,17 @@ Feature: Subplan Service Coverage Boost When spawn is called with spawn_entries set to None Then a ValueError should be raised with text "spawn_entries must not be None" - # --- spawn() raising SpawnValidationError (line 220) --- + # --- spawn() raising SubplanSpawnError (v3.3.0) --- @spawn_validation_error - Scenario: Spawn raises SpawnValidationError when validation fails + Scenario: Spawn raises SubplanSpawnError when validation fails Given a subplan service constructed with a mock decision service And a parent plan for coverage tests And a subplan config with merge strategy forcibly set to None And a spawn entry with a valid spawn decision for coverage tests When spawn is called and validation is expected to fail - Then a SpawnValidationError should be raised - And the SpawnValidationError should mention "merge strategy" + Then a SubplanSpawnError should be raised + And the SubplanSpawnError should mention "merge strategy" # --- validate_spawn() with config=None (line 301) --- diff --git a/src/cleveragents/application/services/__init__.py b/src/cleveragents/application/services/__init__.py index 668be0673..def2b29f3 100644 --- a/src/cleveragents/application/services/__init__.py +++ b/src/cleveragents/application/services/__init__.py @@ -309,8 +309,9 @@ if TYPE_CHECKING: from cleveragents.application.services.subplan_service import ( SpawnResult as SpawnResult, ) - from cleveragents.application.services.subplan_service import ( - SpawnValidationError as SpawnValidationError, + # SubplanSpawnError is centralized in exceptions.py (v3.3.0) + from cleveragents.core.exceptions import ( + SubplanSpawnError as SubplanSpawnError, ) from cleveragents.application.services.subplan_service import ( SpawnValidationResult as SpawnValidationResult, @@ -531,7 +532,8 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "SpawnEntry": ("subplan_service", "SpawnEntry"), "SpawnMetadata": ("subplan_service", "SpawnMetadata"), "SpawnResult": ("subplan_service", "SpawnResult"), - "SpawnValidationError": ("subplan_service", "SpawnValidationError"), + # SubplanSpawnError relocated to cleveragents.core.exceptions (v3.3.0) + "SubplanSpawnError": ("..core/exceptions", "SubplanSpawnError"), "SpawnValidationResult": ("subplan_service", "SpawnValidationResult"), "SubplanService": ("subplan_service", "SubplanService"), "TemporalService": ("temporal_service", "TemporalService"), diff --git a/src/cleveragents/application/services/subplan_service.py b/src/cleveragents/application/services/subplan_service.py index 26fde8368..bb462a5b8 100644 --- a/src/cleveragents/application/services/subplan_service.py +++ b/src/cleveragents/application/services/subplan_service.py @@ -41,7 +41,10 @@ from typing import TYPE_CHECKING from ulid import ULID -from cleveragents.core.exceptions import ValidationError +from cleveragents.core.exceptions import ( + SubplanSpawnError, + ValidationError, +) from cleveragents.domain.models.core.decision import Decision, DecisionType from cleveragents.domain.models.core.plan import ( ExecutionMode, @@ -134,24 +137,6 @@ class SpawnResult: child_plans: list[Plan] = field(default_factory=list) -# --------------------------------------------------------------------------- -# Exceptions -# --------------------------------------------------------------------------- - - -class SpawnValidationError(ValidationError): - """Raised when spawn validation fails. - - Attributes: - validation_errors: List of individual validation failure messages. - """ - - def __init__(self, validation_errors: list[str]) -> None: - self.validation_errors: list[str] = validation_errors - errors_str: str = "; ".join(validation_errors) - super().__init__(f"Spawn validation failed: {errors_str}") - - # --------------------------------------------------------------------------- # Service # --------------------------------------------------------------------------- @@ -228,7 +213,7 @@ class SubplanService: ValueError: If *parent_plan*, *config*, or *spawn_entries* is ``None``. ValueError: If *spawn_entries* is empty. - SpawnValidationError: If validation fails. + SubplanSpawnError: If validation fails. """ if parent_plan is None: raise ValueError("parent_plan must not be None") @@ -246,7 +231,8 @@ class SubplanService: available_resources=available_resources, ) if not validation.valid: - raise SpawnValidationError(validation.errors) + errors_str = "; ".join(validation.errors) + raise SubplanSpawnError(f"Spawn validation failed: {errors_str}") # Retrieve parent plan's invariant_enforced decisions once, before # creating child plans. These will be propagated to every child. @@ -555,7 +541,6 @@ __all__: list[str] = [ "SpawnEntry", "SpawnMetadata", "SpawnResult", - "SpawnValidationError", "SpawnValidationResult", "SubplanService", ] diff --git a/src/cleveragents/core/exceptions.py b/src/cleveragents/core/exceptions.py index f07dd26af..2426336c9 100644 --- a/src/cleveragents/core/exceptions.py +++ b/src/cleveragents/core/exceptions.py @@ -359,6 +359,116 @@ class InvariantViolationError(BusinessRuleViolation): self.action_text = action_text +# Subplan Errors (v3.3.0) +class SubplanSpawnError(PlanError): + """Raised when subplan spawning fails. + + Covers spawn-level failures including validation errors, upstream + service unavailability, and plan state constraints that prevent + child plans from being created. + + Attributes: + message: Human-readable error description. + details: Optional structured context (e.g. plan_id). + """ + + def __init__( + self, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + """Initialize subplan spawn error. + + Args: + message: Human-readable description of the failure. + details: Optional structured context. + """ + super().__init__(message, details) + + +class SubplanExecutionError(DomainError): + """Raised when a subplan execution fails after spawning. + + Covers runtime failures during child-plan execution such as + sandbox errors, merge conflicts (when FAIL_ON_CONFLICT), and + per-subplan timeouts. + + Attributes: + message: Human-readable error description. + details: Optional structured context (e.g., subplan_id). + """ + + def __init__( + self, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + """Initialize subplan execution error. + + Args: + message: Human-readable description of the failure. + details: Optional structured context. + """ + super().__init__(message, details) + + +class MaxParallelExceededError(BusinessRuleViolation): + """Raised when concurrent subplans exceed the configured max limit. + + Attributes: + plan_id: ULID of the parent plan that triggered the limit. + requested: Number of subplans requested to execute concurrently. + max_allowed: Configured maximum parallel subplans. + """ + + def __init__( + self, + plan_id: str, + requested: int, + max_allowed: int, + ) -> None: + """Initialize max-parallel-exceeded error. + + Args: + plan_id: ULID of the parent plan. + requested: Number of subplans attempted. + max_allowed: The configured ``max_parallel`` value. + """ + super().__init__( + f"Concurrent subplan count {requested} exceeds " + f"max_parallel limit {max_allowed} for plan {plan_id}" + ) + self.plan_id = plan_id + self.requested = requested + self.max_allowed = max_allowed + + +class SubplanDepthLimitError(BusinessRuleViolation): + """Raised when subplan nesting exceeds the configured depth limit. + + Attributes: + depth_limit: Maximum allowed nesting depth. + current_depth: The depth that was exceeded. + """ + + def __init__( + self, + depth_limit: int, + current_depth: int, + ) -> None: + """Initialize depth-limit error. + + Args: + depth_limit: Maximum allowed nesting depth. + current_depth: The actual nesting depth encountered. + """ + super().__init__( + f"Subplan nesting depth {current_depth} exceeds limit {depth_limit}" + ) + self.depth_limit = depth_limit + self.current_depth = current_depth + + __all__ = [ "AuthenticationError", "AuthorizationError", @@ -375,6 +485,7 @@ __all__ = [ "InvariantViolationError", "LockConflictError", "LockExpiredError", + "MaxParallelExceededError", "MigrationNotApprovedError", "MissingConfigurationError", "ModelNotAvailableError", @@ -386,6 +497,9 @@ __all__ = [ "ResourceConflictError", "ResourceNotFoundError", "StreamRoutingError", + "SubplanDepthLimitError", + "SubplanExecutionError", + "SubplanSpawnError", "TokenLimitExceededError", "ToolTypeMismatchError", "ValidationError", diff --git a/vulture_whitelist.py b/vulture_whitelist.py index aaacc217b..d0cacee43 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -562,7 +562,10 @@ SpawnMetadata # noqa: B018, F821 SpawnEntry # noqa: B018, F821 SpawnResult # noqa: B018, F821 SpawnValidationResult # noqa: B018, F821 -SpawnValidationError # noqa: B018, F821 +SubplanSpawnError # noqa: B018, F821 (v3.3.0, replaced SpawnValidationError) +SubplanExecutionError # noqa: B018, F821 (v3.3.0) +MaxParallelExceededError # noqa: B018, F821 (v3.3.0) +SubplanDepthLimitError # noqa: B018, F821 (v3.3.0) SubplanService # noqa: B018, F821 subplan_service # noqa: B018, F821 validation_errors # noqa: B018, F821