Files
temp/robot/helper_plan_executor_subplan_spawning.py
freemo 8608584e99 fix(plan-executor): wire SubplanService and SubplanExecutionService into Execute phase
Implemented optional SubplanService and SubplanExecutionService wiring in PlanExecutor.__init__() (None = no-op).
- Added _spawn_subplans() helper:
  - Queries spawn decisions via SubplanService.get_spawn_decisions() and calls SubplanService.spawn() for each decision.
  - No-ops when there are no spawn decisions.
- Added _execute_subplans() helper:
  - Delegates to SubplanExecutionService.execute_all() to run spawned subplans, handling both sequential and parallel groups as dictated by decisions.
- Added _apply_subplan_results_to_plan() helper:
  - Updates parent plan status tracking when child subplans fail.
  - Annotates error_details with failed_subplan_ids when appropriate.
- Integrated spawning and execution into existing flow:
  - Called _spawn_subplans() and _execute_subplans() from both _run_execute_with_runtime() and _run_execute_with_stub() after actor completion.
- Introduced PlanExecutor properties:
  - subplan_service and subplan_execution_service for external wiring and testability.
- Added tests and scenarios:
  - Behave feature file with 6 scenarios covering subplan_spawn, subplan_parallel_spawn, no-op, and failure tracking.
  - Robot Framework integration test suite with 6 end-to-end subplan spawning test cases.

Key design decisions
- Optional services (None = no-op) to maintain backward compatibility with existing deployments.
- Subplan spawning is a no-op when no spawn decisions exist, avoiding unnecessary work.
- Parent plan error_details is annotated with failed_subplan_ids when a child subplan fails to aid debugging and traceability.
- Both runtime and stub execute paths share the same spawning logic to ensure consistent behavior across execution modes.

ISSUES CLOSED: #3561
2026-04-05 20:46:24 +00:00

391 lines
12 KiB
Python

"""Helper script for PlanExecutor subplan spawning integration Robot tests.
Exercises the end-to-end subplan spawning path through PlanExecutor:
- subplan_spawn decision realisation (stub mode)
- subplan_parallel_spawn decision realisation (stub mode)
- no-op when no spawn decisions exist
- parent plan status tracking for child subplan failures
- no-op when SubplanService is not configured
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
from cleveragents.application.services.plan_executor import PlanExecutor
from cleveragents.application.services.subplan_execution_service import (
SubplanExecutionResult,
SubplanExecutionService,
)
from cleveragents.application.services.subplan_service import (
SpawnEntry,
SpawnResult,
SubplanService,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
Decision,
DecisionType,
)
from cleveragents.domain.models.core.plan import (
ExecutionMode,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
SubplanConfig,
SubplanStatus,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PLAN_ID = "01KNFMGJSG67S6RG9TVXV205TQ"
_ROOT_ID = "01KNFMGJSH67S6RG9TVXV205TR"
_DEC_ID = "01KNFMGJSH67S6RG9TVXV205TS"
_SUBPLAN_ID = "01KNFMGJSH67S6RG9TVXV205TT"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_plan(
*,
subplan_config: SubplanConfig | None = None,
) -> Plan:
plan = Plan(
identity=PlanIdentity(
plan_id=_PLAN_ID,
root_plan_id=_ROOT_ID,
),
namespaced_name=NamespacedName(namespace="local", name="robot-test-plan"),
description="Robot test plan for subplan spawning",
action_name="local/robot-test-action",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.QUEUED,
subplan_config=subplan_config,
)
plan.decision_root_id = _ROOT_ID
return plan
def _make_decision(
decision_type: DecisionType = DecisionType.SUBPLAN_SPAWN,
) -> Decision:
return Decision(
decision_id=_DEC_ID,
plan_id=_PLAN_ID,
decision_type=decision_type,
sequence_number=0,
question="Spawn a child plan?",
chosen_option="local/sub-action",
context_snapshot=ContextSnapshot(relevant_resources=[]),
)
def _make_spawn_result(
execution_mode: str = ExecutionMode.SEQUENTIAL,
) -> SpawnResult:
sub_status = SubplanStatus(
subplan_id=_SUBPLAN_ID,
action_name="local/sub-action",
status=ProcessingState.QUEUED,
)
return SpawnResult(
spawned_statuses=[sub_status],
metadata={},
total_spawned=1,
execution_mode=execution_mode,
child_plans=[],
)
def _make_exec_result(
all_succeeded: bool = True,
failed_ids: list[str] | None = None,
) -> SubplanExecutionResult:
failed = failed_ids or []
status = ProcessingState.COMPLETE if all_succeeded else ProcessingState.ERRORED
sub_status = SubplanStatus(
subplan_id=_SUBPLAN_ID,
action_name="local/sub-action",
status=status,
error=None if all_succeeded else "ChildExecError: timeout",
)
return SubplanExecutionResult(
all_succeeded=all_succeeded,
statuses=[sub_status],
merge_result=None,
total_duration_ms=10,
failed_subplan_ids=failed,
)
def _make_lifecycle(plan: Plan) -> MagicMock:
lcs = MagicMock()
lcs.get_plan.return_value = plan
lcs.start_execute = MagicMock()
lcs.complete_execute = MagicMock()
lcs.fail_execute = MagicMock()
lcs._commit_plan = MagicMock()
return lcs
def _make_subplan_service(
decisions: list[Decision] | None = None,
spawn_result: SpawnResult | None = None,
) -> MagicMock:
svc = MagicMock(spec=SubplanService)
svc.get_spawn_decisions.return_value = decisions or []
svc.build_spawn_entries.return_value = [
SpawnEntry(decision=d, action_name="local/sub-action")
for d in (decisions or [])
]
svc.spawn.return_value = spawn_result or _make_spawn_result()
return svc
def _make_subplan_execution_service(
exec_result: SubplanExecutionResult | None = None,
) -> MagicMock:
svc = MagicMock(spec=SubplanExecutionService)
svc.execute_all.return_value = exec_result or _make_exec_result()
return svc
def _make_execute_actor(plan: Plan) -> MagicMock:
"""Build a mock execute actor that returns a minimal result."""
actor = MagicMock()
result = MagicMock()
result.changeset_id = "01JROBOT000000000000CS00001"
result.sandbox_refs = []
result.tool_calls_count = 0
actor.execute.return_value = result
return actor
# ---------------------------------------------------------------------------
# Test functions
# ---------------------------------------------------------------------------
def _test_spawn_decision_realisation() -> None:
"""subplan_spawn decision is realised as child plan execution."""
decision = _make_decision(DecisionType.SUBPLAN_SPAWN)
spawn_result = _make_spawn_result()
exec_result = _make_exec_result(all_succeeded=True)
mock_subplan_svc = _make_subplan_service(
decisions=[decision],
spawn_result=spawn_result,
)
mock_exec_svc = _make_subplan_execution_service(exec_result=exec_result)
plan = _make_plan()
lcs = _make_lifecycle(plan)
execute_actor = _make_execute_actor(plan)
executor = PlanExecutor(
lifecycle_service=lcs,
execute_actor=execute_actor,
subplan_service=mock_subplan_svc,
subplan_execution_service=mock_exec_svc,
)
executor.run_execute(plan_id=_PLAN_ID)
# Verify the full spawning pipeline was invoked
mock_subplan_svc.get_spawn_decisions.assert_called_once_with(_PLAN_ID)
mock_subplan_svc.build_spawn_entries.assert_called_once()
mock_subplan_svc.spawn.assert_called_once()
mock_exec_svc.execute_all.assert_called_once()
# Verify _commit_plan was called (plan state persisted)
lcs._commit_plan.assert_called()
print("spawn-decision-realisation-ok")
def _test_parallel_spawn_decision_realisation() -> None:
"""subplan_parallel_spawn decision triggers parallel execution."""
decision = _make_decision(DecisionType.SUBPLAN_PARALLEL_SPAWN)
spawn_result = _make_spawn_result(execution_mode=ExecutionMode.PARALLEL)
exec_result = _make_exec_result(all_succeeded=True)
mock_subplan_svc = _make_subplan_service(
decisions=[decision],
spawn_result=spawn_result,
)
mock_exec_svc = _make_subplan_execution_service(exec_result=exec_result)
plan = _make_plan()
lcs = _make_lifecycle(plan)
execute_actor = _make_execute_actor(plan)
executor = PlanExecutor(
lifecycle_service=lcs,
execute_actor=execute_actor,
subplan_service=mock_subplan_svc,
subplan_execution_service=mock_exec_svc,
)
executor.run_execute(plan_id=_PLAN_ID)
mock_subplan_svc.get_spawn_decisions.assert_called_once_with(_PLAN_ID)
mock_subplan_svc.spawn.assert_called_once()
mock_exec_svc.execute_all.assert_called_once()
print("parallel-spawn-decision-realisation-ok")
def _test_noop_when_no_spawn_decisions() -> None:
"""No-op when no spawn decisions are recorded."""
mock_subplan_svc = _make_subplan_service(decisions=[])
mock_exec_svc = _make_subplan_execution_service()
plan = _make_plan()
lcs = _make_lifecycle(plan)
execute_actor = _make_execute_actor(plan)
executor = PlanExecutor(
lifecycle_service=lcs,
execute_actor=execute_actor,
subplan_service=mock_subplan_svc,
subplan_execution_service=mock_exec_svc,
)
executor.run_execute(plan_id=_PLAN_ID)
# get_spawn_decisions called but spawn and execute_all NOT called
mock_subplan_svc.get_spawn_decisions.assert_called_once_with(_PLAN_ID)
mock_subplan_svc.spawn.assert_not_called()
mock_exec_svc.execute_all.assert_not_called()
print("noop-no-spawn-decisions-ok")
def _test_noop_when_no_subplan_service() -> None:
"""No-op when SubplanService is not configured."""
plan = _make_plan()
lcs = _make_lifecycle(plan)
execute_actor = _make_execute_actor(plan)
executor = PlanExecutor(
lifecycle_service=lcs,
execute_actor=execute_actor,
subplan_service=None,
subplan_execution_service=None,
)
# Should complete without error
executor.run_execute(plan_id=_PLAN_ID)
# Verify lifecycle completed normally
lcs.complete_execute.assert_called_once_with(_PLAN_ID)
print("noop-no-subplan-service-ok")
def _test_parent_status_tracking_on_failure() -> None:
"""Parent plan error_details records failed subplan IDs."""
decision = _make_decision(DecisionType.SUBPLAN_SPAWN)
spawn_result = _make_spawn_result()
exec_result = _make_exec_result(
all_succeeded=False,
failed_ids=[_SUBPLAN_ID],
)
mock_subplan_svc = _make_subplan_service(
decisions=[decision],
spawn_result=spawn_result,
)
mock_exec_svc = _make_subplan_execution_service(exec_result=exec_result)
plan = _make_plan()
lcs = _make_lifecycle(plan)
execute_actor = _make_execute_actor(plan)
executor = PlanExecutor(
lifecycle_service=lcs,
execute_actor=execute_actor,
subplan_service=mock_subplan_svc,
subplan_execution_service=mock_exec_svc,
)
executor.run_execute(plan_id=_PLAN_ID)
# Verify _commit_plan was called with a plan that has failure info
commit_calls = lcs._commit_plan.call_args_list
assert commit_calls, "Expected _commit_plan to be called"
found_failure_info = False
for c in commit_calls:
committed_plan = c[0][0]
details = getattr(committed_plan, "error_details", None) or {}
if (
"failed_subplan_ids" in details
and details.get("subplan_execution_failed") == "true"
):
found_failure_info = True
break
assert found_failure_info, (
f"Expected error_details with failure info in _commit_plan calls. "
f"Calls: {commit_calls}"
)
print("parent-status-tracking-failure-ok")
def _test_subplan_service_property() -> None:
"""PlanExecutor exposes subplan_service and subplan_execution_service properties."""
mock_subplan_svc = _make_subplan_service()
mock_exec_svc = _make_subplan_execution_service()
plan = _make_plan()
lcs = _make_lifecycle(plan)
executor = PlanExecutor(
lifecycle_service=lcs,
subplan_service=mock_subplan_svc,
subplan_execution_service=mock_exec_svc,
)
assert executor.subplan_service is mock_subplan_svc
assert executor.subplan_execution_service is mock_exec_svc
print("subplan-service-properties-ok")
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
COMMANDS: dict[str, object] = {
"spawn-decision-realisation": _test_spawn_decision_realisation,
"parallel-spawn-decision-realisation": _test_parallel_spawn_decision_realisation,
"noop-no-spawn-decisions": _test_noop_when_no_spawn_decisions,
"noop-no-subplan-service": _test_noop_when_no_subplan_service,
"parent-status-tracking-failure": _test_parent_status_tracking_on_failure,
"subplan-service-properties": _test_subplan_service_property,
}
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: helper_plan_executor_subplan_spawning.py <command>")
sys.exit(1)
cmd = sys.argv[1]
fn = COMMANDS.get(cmd)
if fn is None:
print(f"Unknown command: {cmd}")
sys.exit(1)
fn() # type: ignore[operator]