Files
cleveragents-core/features/steps/subplan_spawn_service_steps.py
HAL9000 5e89f1016c refactor(subplans): Centralize subplan errors per v3.3.0 spec (#8725)
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
2026-06-02 19:50:41 -04:00

589 lines
19 KiB
Python

"""Step definitions for subplan spawn service scenarios."""
from __future__ import annotations
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SubplanService,
)
from cleveragents.core.exceptions import SubplanSpawnError
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from cleveragents.domain.models.core.plan import (
ExecutionMode,
NamespacedName,
Plan,
PlanIdentity,
SubplanConfig,
)
_PLAN_ID = "01HGZ6FE0AQDYTR4BXVQZ6PN00"
_ROOT_ID = "01HGZ6FE0AQDYTR4BXVQZ6RF00"
_DEC_ID1 = "01HGZ6FE0AQDYTR4BXVQZ6DA00"
_DEC_ID2 = "01HGZ6FE0AQDYTR4BXVQZ6DB00"
_DEC_ID3 = "01HGZ6FE0AQDYTR4BXVQZ6DC00"
def _mock_decision_service() -> MagicMock:
"""Create a mock DecisionService."""
svc: MagicMock = MagicMock()
svc.list_by_type = MagicMock(return_value=[])
return svc
def _make_decision(
decision_id: str,
plan_id: str = _PLAN_ID,
decision_type: DecisionType = DecisionType.SUBPLAN_SPAWN,
sequence: int = 0,
chosen_option: str = "local/sub-action",
resource_ids: list[str] | None = None,
) -> Decision:
"""Create a Decision with sensible defaults."""
resources: list[ResourceRef] = []
if resource_ids:
resources = [ResourceRef(resource_id=rid) for rid in resource_ids]
return Decision(
decision_id=decision_id,
plan_id=plan_id,
decision_type=decision_type,
sequence_number=sequence,
question="Should we spawn a child plan?",
chosen_option=chosen_option,
context_snapshot=ContextSnapshot(relevant_resources=resources),
)
def _make_plan(
plan_id: str = _PLAN_ID,
parent_plan_id: str | None = None,
root_plan_id: str | None = None,
) -> Plan:
"""Create a minimal Plan for testing."""
return Plan(
identity=PlanIdentity(
plan_id=plan_id,
parent_plan_id=parent_plan_id,
root_plan_id=root_plan_id,
),
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
description="A test plan for subplan spawn",
action_name="local/test-action",
)
# ---- Successful spawn ----
@given("a parent plan with a subplan spawn decision")
def step_given_parent_with_spawn_decision(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(_DEC_ID1)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
description="Test subplan",
)
]
@given("a subplan config with sequential execution mode")
def step_given_config_sequential(context: Context) -> None:
context.subplan_config = SubplanConfig(
execution_mode=ExecutionMode.SEQUENTIAL,
)
@when("the subplan service spawns child plans")
def step_when_spawn(context: Context) -> None:
context.spawn_error = None
try:
context.spawn_result = context.subplan_service.spawn(
parent_plan=context.parent_plan,
config=context.subplan_config,
spawn_entries=context.spawn_entries,
available_resources=getattr(context, "available_resources", None),
)
except (SubplanSpawnError, ValueError) as exc:
context.spawn_error = exc
context.spawn_result = None
@then("{count:d} child plan status should be created")
def step_then_count_statuses_singular(context: Context, count: int) -> None:
assert context.spawn_result is not None, f"Spawn failed: {context.spawn_error}"
actual: int = len(context.spawn_result.spawned_statuses)
assert actual == count, f"Expected {count} statuses, got {actual}"
@then("{count:d} child plan statuses should be created")
def step_then_count_statuses_plural(context: Context, count: int) -> None:
assert context.spawn_result is not None, f"Spawn failed: {context.spawn_error}"
actual: int = len(context.spawn_result.spawned_statuses)
assert actual == count, f"Expected {count} statuses, got {actual}"
@then("the spawn metadata should contain the parent plan id")
def step_then_metadata_parent_id(context: Context) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.parent_plan_id == context.parent_plan.identity.plan_id
@then("the spawn metadata should contain the root plan id")
def step_then_metadata_root_id(context: Context) -> None:
assert context.spawn_result is not None
plan: Plan = context.parent_plan
expected_root: str = plan.identity.root_plan_id or plan.identity.plan_id
for meta in context.spawn_result.metadata.values():
assert meta.root_plan_id == expected_root
@then("the spawn metadata should contain the execution mode")
def step_then_metadata_exec_mode(context: Context) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.execution_mode in {m.value for m in ExecutionMode}
# ---- Multiple entries ----
@given("a parent plan with {count:d} subplan spawn decisions")
def step_given_parent_with_n_decisions(context: Context, count: int) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
context.spawn_entries = []
for i in range(count):
# Build unique ULID-like IDs
suffix: str = f"{i:02d}"
did: str = f"01HGZ6FE0AQDYTR4BXVQZ6D{suffix}0"
dec: Decision = _make_decision(did, sequence=i)
context.spawn_entries.append(
SpawnEntry(
decision=dec,
action_name=f"local/sub-action-{i}",
description=f"Subplan {i}",
)
)
@given("a subplan config with parallel execution mode and max_parallel {n:d}")
def step_given_config_parallel(context: Context, n: int) -> None:
context.subplan_config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=n,
)
@then("each status should have a unique subplan id")
def step_then_unique_ids(context: Context) -> None:
assert context.spawn_result is not None
ids: list[str] = [s.subplan_id for s in context.spawn_result.spawned_statuses]
assert len(ids) == len(set(ids)), f"Duplicate IDs found: {ids}"
@then("the spawn result total_spawned should be {count:d}")
def step_then_total_spawned(context: Context, count: int) -> None:
assert context.spawn_result is not None
assert context.spawn_result.total_spawned == count
# ---- Validation: resource scopes ----
@given('a parent plan with a spawn entry targeting resource "{res_id}"')
def step_given_entry_with_resource(context: Context, res_id: str) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(
_DEC_ID1,
resource_ids=[res_id],
)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
target_resources=[res_id],
)
]
@given('available resources are "{res_a}" and "{res_b}"')
def step_given_available_resources(
context: Context,
res_a: str,
res_b: str,
) -> None:
context.available_resources = frozenset({res_a, res_b})
@when("the subplan service validates the spawn request")
def step_when_validate(context: Context) -> None:
context.validation_result = context.subplan_service.validate_spawn(
config=context.subplan_config,
spawn_entries=context.spawn_entries,
available_resources=getattr(context, "available_resources", None),
)
@then("the validation result should be invalid")
def step_then_invalid(context: Context) -> None:
assert not context.validation_result.valid, (
"Expected invalid, got valid with no errors"
)
@then("the validation errors should mention unresolved resource")
def step_then_error_mentions_resource(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "resource" in errors_str.lower(), (
f"Expected 'resource' in errors: {context.validation_result.errors}"
)
# ---- Validation: merge strategy ----
@given("a parent plan with a valid spawn entry")
def step_given_valid_entry(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(_DEC_ID1)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
)
]
@given("a subplan config with no merge strategy")
def step_given_config_no_merge(context: Context) -> None:
# Create a config and then forcibly set merge_strategy to None
config: SubplanConfig = SubplanConfig(
execution_mode=ExecutionMode.SEQUENTIAL,
)
# Pydantic models allow assignment when validate_assignment is True
# We use model_copy to create a version with None merge_strategy
# Since SubplanMergeStrategy is required, we use object.__setattr__
object.__setattr__(config, "merge_strategy", None)
context.subplan_config = config
@then("the validation errors should mention merge strategy")
def step_then_error_mentions_merge(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "merge strategy" in errors_str.lower(), (
f"Expected 'merge strategy' in errors: {context.validation_result.errors}"
)
# ---- Validation: max_parallel ----
@then("the validation errors should mention max_parallel")
def step_then_error_mentions_max_parallel(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "max_parallel" in errors_str.lower(), (
f"Expected 'max_parallel' in errors: {context.validation_result.errors}"
)
# ---- Metadata persistence ----
@given('a parent plan that is a subplan with root plan id "{root_id}"')
def step_given_subplan_parent(context: Context, root_id: str) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan(
parent_plan_id="01HGZ6FE0AQDYTR4BXVQZ6PP00",
root_plan_id=root_id,
)
@given("a valid spawn decision")
def step_given_valid_spawn_decision(context: Context) -> None:
dec: Decision = _make_decision(_DEC_ID1)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
)
]
@then('the spawn metadata root_plan_id should be "{expected}"')
def step_then_metadata_root_equals(context: Context, expected: str) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.root_plan_id == expected, (
f"Expected root '{expected}', got '{meta.root_plan_id}'"
)
@then('the spawn metadata execution_mode should be "{expected}"')
def step_then_metadata_mode_equals(context: Context, expected: str) -> None:
assert context.spawn_result is not None
for meta in context.spawn_result.metadata.values():
assert meta.execution_mode == expected, (
f"Expected mode '{expected}', got '{meta.execution_mode}'"
)
# ---- Decision type validation ----
@given("a parent plan with a non-spawn decision type")
def step_given_non_spawn_decision(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(
_DEC_ID1,
decision_type=DecisionType.STRATEGY_CHOICE,
)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
)
]
@then("the validation errors should mention decision type")
def step_then_error_mentions_decision_type(context: Context) -> None:
errors_str: str = " ".join(context.validation_result.errors)
assert "type" in errors_str.lower(), (
f"Expected 'type' in errors: {context.validation_result.errors}"
)
# ---- Get spawn decisions ----
@given('a decision service with spawn decisions for plan "{plan_id}"')
def step_given_decision_service_with_spawns(
context: Context,
plan_id: str,
) -> None:
spawn_dec: Decision = _make_decision(
_DEC_ID1,
plan_id=plan_id,
sequence=0,
)
parallel_dec: Decision = _make_decision(
_DEC_ID2,
plan_id=plan_id,
decision_type=DecisionType.SUBPLAN_PARALLEL_SPAWN,
sequence=1,
)
mock_svc: MagicMock = _mock_decision_service()
mock_svc.list_by_type = MagicMock(
side_effect=lambda pid, dtype: (
[spawn_dec] if dtype == DecisionType.SUBPLAN_SPAWN.value else [parallel_dec]
)
)
context.decision_service = mock_svc
context.subplan_service = SubplanService(
decision_service=mock_svc,
)
context.plan_id = plan_id
@when("the subplan service retrieves spawn decisions")
def step_when_get_spawn_decisions(context: Context) -> None:
context.spawn_decisions = context.subplan_service.get_spawn_decisions(
context.plan_id,
)
@then("the returned decisions should only contain spawn types")
def step_then_only_spawn_types(context: Context) -> None:
for dec in context.spawn_decisions:
assert dec.decision_type in (
DecisionType.SUBPLAN_SPAWN,
DecisionType.SUBPLAN_PARALLEL_SPAWN,
), f"Unexpected decision type: {dec.decision_type}"
# ---- Build spawn entries ----
@given("a list of spawn decisions with resource refs")
def step_given_decisions_with_resources(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.input_decisions = [
_make_decision(
_DEC_ID1,
resource_ids=["01HGZ6FE0AQDYTR4BXVQZ6RA00"],
chosen_option="local/test-sub",
),
_make_decision(
_DEC_ID2,
sequence=1,
resource_ids=[
"01HGZ6FE0AQDYTR4BXVQZ6RB00",
"01HGZ6FE0AQDYTR4BXVQZ6RC00",
],
chosen_option="local/test-sub-2",
),
]
@when("the subplan service builds spawn entries")
def step_when_build_entries(context: Context) -> None:
context.built_entries = context.subplan_service.build_spawn_entries(
context.input_decisions,
)
@then("each entry should have action_name and target_resources")
def step_then_entries_have_fields(context: Context) -> None:
for entry in context.built_entries:
assert entry.action_name, "Entry missing action_name"
# First entry should have 1 resource, second should have 2
assert len(context.built_entries) == 2
assert len(context.built_entries[0].target_resources) == 1
assert len(context.built_entries[1].target_resources) == 2
# ---- Service construction ----
@when("the subplan service is constructed with None decision_service")
def step_when_construct_none(context: Context) -> None:
context.construct_error = None
try:
SubplanService(decision_service=None) # type: ignore[arg-type]
except ValueError as exc:
context.construct_error = exc
@then("a ValueError should be raised for subplan service")
def step_then_value_error(context: Context) -> None:
error: Exception | None = getattr(
context,
"construct_error",
None,
) or getattr(context, "spawn_error", None)
assert error is not None, "Expected ValueError but none was raised"
assert isinstance(error, ValueError), (
f"Expected ValueError, got {type(error).__name__}"
)
# ---- Null guard scenarios ----
@given("a valid subplan service")
def step_given_valid_service(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.subplan_config = SubplanConfig()
@given("a parent plan with identity")
def step_given_plan_with_identity(context: Context) -> None:
context.parent_plan = _make_plan()
@when("spawn is called with None parent_plan")
def step_when_spawn_none_plan(context: Context) -> None:
context.spawn_error = None
try:
context.subplan_service.spawn(
parent_plan=None, # type: ignore[arg-type]
config=context.subplan_config,
spawn_entries=[
SpawnEntry(
decision=_make_decision(_DEC_ID1),
action_name="local/test",
)
],
)
except ValueError as exc:
context.spawn_error = exc
@when("spawn is called with empty spawn_entries")
def step_when_spawn_empty_entries(context: Context) -> None:
context.spawn_error = None
try:
context.subplan_service.spawn(
parent_plan=context.parent_plan,
config=context.subplan_config,
spawn_entries=[],
)
except ValueError as exc:
context.spawn_error = exc
@then('a spawn ValueError should be raised with message "{msg}"')
def step_then_value_error_msg(context: Context, msg: str) -> None:
error: Exception | None = getattr(
context,
"construct_error",
None,
) or getattr(context, "spawn_error", None)
assert error is not None, f"Expected ValueError with '{msg}'"
assert isinstance(error, ValueError)
assert msg in str(error), f"Expected '{msg}' in '{error}'"
# ---- Parallel spawn decision ----
@given("a parent plan with a subplan_parallel_spawn decision")
def step_given_parallel_spawn_decision(context: Context) -> None:
context.decision_service = _mock_decision_service()
context.subplan_service = SubplanService(
decision_service=context.decision_service,
)
context.parent_plan = _make_plan()
dec: Decision = _make_decision(
_DEC_ID1,
decision_type=DecisionType.SUBPLAN_PARALLEL_SPAWN,
)
context.spawn_entries = [
SpawnEntry(
decision=dec,
action_name="local/parallel-sub",
)
]