5e89f1016c
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
589 lines
19 KiB
Python
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",
|
|
)
|
|
]
|