Files
cleveragents-core/robot/helper_subplan_spawn.py
freemo c43e1f8260
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 19s
CI / build (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 32s
CI / unit_tests (pull_request) Successful in 1m52s
CI / docker (pull_request) Successful in 42s
CI / integration_tests (pull_request) Successful in 2m59s
CI / coverage (pull_request) Successful in 4m14s
CI / lint (push) Successful in 15s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 30s
CI / typecheck (push) Successful in 1m3s
CI / build (push) Successful in 18s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m27s
CI / integration_tests (push) Successful in 3m0s
CI / docker (push) Successful in 50s
CI / coverage (push) Successful in 5m8s
CI / benchmark-publish (push) Has been cancelled
CI / benchmark-regression (pull_request) Successful in 29m24s
feat(service): add subplan service and spawn workflow
Add SubplanService for building child plans from DecisionService spawn
entries and SubplanConfig. The service validates resource scopes, merge
strategies, and max_parallel bounds before spawning.

Key additions:
- SubplanService: Orchestrates child plan creation from spawn entries
- SpawnMetadata: Persisted metadata (spawn_decision_id, parent/root
  plan IDs, execution mode) for status output
- Spawn validation: Checks resource scopes, merge strategy, and
  parallelism bounds before spawning
- Documentation: subplan_service.md with spawn workflow and lifecycle

ISSUES CLOSED: #197
2026-03-02 09:41:25 -05:00

252 lines
7.9 KiB
Python

"""Helper script for subplan spawn Robot Framework smoke tests.
Exercises SubplanService spawn workflow, validation, and metadata
persistence without requiring the full service layer.
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SubplanService,
)
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:
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:
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="Spawn 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:
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="Robot test plan",
action_name="local/test-action",
)
def _spawn_single() -> None:
"""Spawn a single child plan from a decision entry."""
svc = SubplanService(decision_service=_mock_decision_service())
plan = _make_plan()
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
dec = _make_decision(_DEC_ID1)
entries = [SpawnEntry(decision=dec, action_name="local/sub-action")]
result = svc.spawn(parent_plan=plan, config=config, spawn_entries=entries)
assert result.total_spawned == 1
assert len(result.spawned_statuses) == 1
meta = next(iter(result.metadata.values()))
assert meta.parent_plan_id == _PLAN_ID
print("spawn-single-ok")
def _spawn_multiple() -> None:
"""Spawn multiple child plans from decision entries."""
svc = SubplanService(decision_service=_mock_decision_service())
plan = _make_plan()
config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=5,
)
entries = []
for i, did in enumerate([_DEC_ID1, _DEC_ID2, _DEC_ID3]):
dec = _make_decision(did, sequence=i)
entries.append(SpawnEntry(decision=dec, action_name=f"local/sub-{i}"))
result = svc.spawn(parent_plan=plan, config=config, spawn_entries=entries)
assert result.total_spawned == 3
ids = [s.subplan_id for s in result.spawned_statuses]
assert len(ids) == len(set(ids)), "Duplicate IDs"
print("spawn-multiple-ok")
def _validate_resource_scope() -> None:
"""Validate spawn with missing resource scope."""
svc = SubplanService(decision_service=_mock_decision_service())
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
dec = _make_decision(_DEC_ID1, resource_ids=["res-missing"])
entries = [
SpawnEntry(
decision=dec,
action_name="local/sub-action",
target_resources=["res-missing"],
)
]
result = svc.validate_spawn(
config=config,
spawn_entries=entries,
available_resources=frozenset({"res-a", "res-b"}),
)
assert not result.valid
assert any("resource" in e.lower() for e in result.errors)
print("validate-resource-ok")
def _validate_max_parallel() -> None:
"""Validate spawn exceeding max_parallel bound."""
svc = SubplanService(decision_service=_mock_decision_service())
config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
max_parallel=2,
)
entries = []
for i in range(5):
did = f"01HGZ6FE0AQDYTR4BXVQZ6D{i:02d}0"
dec = _make_decision(did, sequence=i)
entries.append(SpawnEntry(decision=dec, action_name=f"local/sub-{i}"))
result = svc.validate_spawn(config=config, spawn_entries=entries)
assert not result.valid
assert any("max_parallel" in e.lower() for e in result.errors)
print("validate-max-parallel-ok")
def _metadata_persistence() -> None:
"""Verify spawn metadata contains correct plan IDs."""
svc = SubplanService(decision_service=_mock_decision_service())
plan = _make_plan(
parent_plan_id="01HGZ6FE0AQDYTR4BXVQZ6PP00",
root_plan_id=_ROOT_ID,
)
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
dec = _make_decision(_DEC_ID1)
entries = [SpawnEntry(decision=dec, action_name="local/sub-action")]
result = svc.spawn(parent_plan=plan, config=config, spawn_entries=entries)
meta = next(iter(result.metadata.values()))
assert meta.root_plan_id == _ROOT_ID
assert meta.execution_mode == "sequential"
print("metadata-persistence-ok")
def _validation_guards() -> None:
"""Verify service rejects invalid construction and inputs."""
# None decision_service
try:
SubplanService(decision_service=None) # type: ignore[arg-type]
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
# None parent_plan
svc = SubplanService(decision_service=_mock_decision_service())
try:
svc.spawn(
parent_plan=None, # type: ignore[arg-type]
config=SubplanConfig(),
spawn_entries=[
SpawnEntry(
decision=_make_decision(_DEC_ID1),
action_name="local/test",
)
],
)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
# Empty spawn_entries
try:
svc.spawn(
parent_plan=_make_plan(),
config=SubplanConfig(),
spawn_entries=[],
)
raise AssertionError("Should have raised ValueError")
except ValueError:
pass
print("validation-guards-ok")
def _build_entries() -> None:
"""Build spawn entries from decisions."""
svc = SubplanService(decision_service=_mock_decision_service())
decisions = [
_make_decision(
_DEC_ID1,
resource_ids=["01HGZ6FE0AQDYTR4BXVQZ6RA00"],
chosen_option="local/test-sub",
),
_make_decision(
_DEC_ID2,
sequence=1,
chosen_option="local/test-sub-2",
),
]
entries = svc.build_spawn_entries(decisions)
assert len(entries) == 2
assert entries[0].action_name == "local/test-sub"
assert len(entries[0].target_resources) == 1
print("build-entries-ok")
_COMMANDS: dict[str, object] = {
"spawn-single": _spawn_single,
"spawn-multiple": _spawn_multiple,
"validate-resource": _validate_resource_scope,
"validate-max-parallel": _validate_max_parallel,
"metadata-persistence": _metadata_persistence,
"validation-guards": _validation_guards,
"build-entries": _build_entries,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(1)
fn = _COMMANDS[sys.argv[1]]
assert callable(fn)
fn()