refactor(subplans): Centralize subplan errors per v3.3.0 spec (#8725)
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m5s
CI / typecheck (pull_request) Successful in 1m14s
CI / benchmark-regression (pull_request) Failing after 56s
CI / helm (pull_request) Successful in 31s
CI / push-validation (pull_request) Successful in 29s
CI / build (pull_request) Successful in 52s
CI / quality (pull_request) Successful in 1m32s
CI / security (pull_request) Successful in 1m38s
CI / e2e_tests (pull_request) Successful in 3m17s
CI / integration_tests (pull_request) Failing after 3m44s
CI / unit_tests (pull_request) Failing after 4m52s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 4s

Move SubplanSpawnError from local subplan_service definition to centralized
cleveragents.core.exceptions alongside four new spec-defined error types:
SubplanExecutionError, MaxParallelExceededError, and SubplanDepthLimitError.

Per the v3.3.0 specification (AUTO-ARCH-6), all subplan-related errors are
defined in exceptions.py with proper inheritance hierarchy under DomainError/
PlanError/BusinessRuleViolation. The old local SpawnValidationError class has
been replaced with SubplanSpawnError(PlanError) with a simplified constructor
API (message string instead of validation_errors list).

Updates:
- exceptions.py: Added 4 subplan error classes + __all__ entries
- subplan_service.py: Remove local SpawnValidationError, import SubplanSpawnError from exceptions
- services/__init__.py: Update TYPE_CHECKING stub and _LAZY_IMPORTS for new location
- vulture_whitelist.py: Replace old entry with new error class names
- docs/reference/subplan_service.md: Update to reference SubplanSpawnError (v3.3.0)
- features/*.feature + steps: Update test references from SpawnValidationError to SubplanSpawnError
This commit is contained in:
2026-05-09 00:23:19 +00:00
parent a253284eb1
commit 17fcd45703
9 changed files with 179 additions and 67 deletions
+10 -9
View File
@@ -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).
+3 -3
View File
@@ -530,10 +530,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
@@ -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}"
@@ -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
+12 -11
View File
@@ -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) ---
@@ -312,8 +312,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,
@@ -535,7 +536,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"),
@@ -29,7 +29,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,
@@ -122,24 +125,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
# ---------------------------------------------------------------------------
@@ -208,7 +193,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")
@@ -226,7 +211,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}")
# Build statuses, metadata, and real child Plan objects
parent_id: str = parent_plan.identity.plan_id
@@ -481,7 +467,6 @@ __all__: list[str] = [
"SpawnEntry",
"SpawnMetadata",
"SpawnResult",
"SpawnValidationError",
"SpawnValidationResult",
"SubplanService",
]
+114
View File
@@ -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",
+4 -1
View File
@@ -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