Files
temp/features/steps/subplan_spawn_orchestration_steps.py
freemo 21e9a65c33 fix(plan): implement end-to-end subplan execution orchestration
Replace the metadata-only subplan spawn with real child Plan domain object
creation. SubplanService.spawn() now creates full Plan instances with proper
PlanIdentity (linking parent_plan_id and root_plan_id), sets them to
PlanPhase.STRATEGIZE / ProcessingState.QUEUED, and returns them in the
SpawnResult.child_plans list for downstream lifecycle orchestration.

Key changes:
- SubplanService.spawn() now creates Plan domain objects for each spawn entry
  with complete PlanIdentity linking, inheriting the parent plan's namespace,
  actors, project links, definition_of_done, and access settings.
- SpawnResult dataclass extended with child_plans: list[Plan] field to carry
  the created child plans alongside the existing metadata and statuses.
- Parent plan's subplan_statuses list is updated with SubplanStatus entries
  tracking each child's lifecycle state.
- Fixed Pyright type errors: added missing definition_of_done, reusable,
  read_only, and server parameters to Plan and NamespacedName constructors.
- Removed @tdd_expected_fail tags from TDD test files since the bug is fixed.
- Added Behave scenarios and Robot integration tests verifying child plan
  creation, lifecycle phase/state, and parent tracking.

ISSUES CLOSED: #823
2026-03-18 17:07:07 +00:00

285 lines
10 KiB
Python

"""Step definitions for subplan spawn → execute → merge orchestration.
These steps exercise ``SubplanService.spawn()`` and verify that the
end-to-end orchestration creates real child ``Plan`` domain objects,
wires parent-child identity, inherits project links, and correctly
updates parent ``SubplanStatus`` entries on child completion/failure.
"""
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,
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,
ProjectLink,
SubplanConfig,
SubplanStatus,
)
_PARENT_PLAN_ID: str = "01HGZ6FE0AQDYTR4BXVQZ6PN00"
_ROOT_PLAN_ID: str = "01HGZ6FE0AQDYTR4BXVQZ6RF00"
_DECISION_ID_1: str = "01HGZ6FE0AQDYTR4BXVQZ6DA00"
_DECISION_ID_2: str = "01HGZ6FE0AQDYTR4BXVQZ6DB00"
def _mock_decision_service() -> MagicMock:
"""Create a mock DecisionService for SubplanService construction."""
svc: MagicMock = MagicMock()
svc.list_by_type = MagicMock(return_value=[])
return svc
def _make_parent_plan(
execution_mode: ExecutionMode = ExecutionMode.SEQUENTIAL,
) -> Plan:
"""Create a parent plan suitable for subplan spawning."""
return Plan(
identity=PlanIdentity(
plan_id=_PARENT_PLAN_ID,
root_plan_id=_ROOT_PLAN_ID,
),
namespaced_name=NamespacedName(namespace="local", name="parent-plan"),
description="Parent plan for subplan spawn orchestration test",
action_name="local/parent-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.PROCESSING,
project_links=[
ProjectLink(project_name="local/my-project"),
],
subplan_config=SubplanConfig(
execution_mode=execution_mode,
),
)
def _make_spawn_entries() -> list[SpawnEntry]:
"""Create spawn entries for testing."""
dec1: Decision = Decision(
decision_id=_DECISION_ID_1,
plan_id=_PARENT_PLAN_ID,
decision_type=DecisionType.SUBPLAN_SPAWN,
sequence_number=0,
question="Spawn child plan for module A?",
chosen_option="local/sub-action-a",
context_snapshot=ContextSnapshot(),
)
dec2: Decision = Decision(
decision_id=_DECISION_ID_2,
plan_id=_PARENT_PLAN_ID,
decision_type=DecisionType.SUBPLAN_SPAWN,
sequence_number=1,
question="Spawn child plan for module B?",
chosen_option="local/sub-action-b",
context_snapshot=ContextSnapshot(),
)
return [
SpawnEntry(decision=dec1, action_name="local/sub-action-a"),
SpawnEntry(decision=dec2, action_name="local/sub-action-b"),
]
# -- Given steps (not duplicating tdd_subplan_spawn_orchestration_steps) ----
@given("a parent plan configured for parallel subplan spawning")
def step_parallel_parent_plan(context: Context) -> None:
"""Set up a parent plan with parallel subplan configuration."""
context.parent_plan = _make_parent_plan(
execution_mode=ExecutionMode.PARALLEL,
)
context.subplan_service = SubplanService(
decision_service=_mock_decision_service(),
)
context.subplan_config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
)
# -- When steps ---------------------------------------------------------------
@when('a child plan fails with error "{error_msg}"')
def step_child_plan_fails(context: Context, error_msg: str) -> None:
"""Simulate a child plan failure by updating the parent's subplan status."""
result: SpawnResult = context.spawn_result
parent: Plan = context.parent_plan
if result.child_plans:
child: Plan = result.child_plans[0]
child_id: str = child.identity.plan_id
# Update the matching subplan status on the parent
for status in parent.subplan_statuses:
if status.subplan_id == child_id:
status.status = ProcessingState.ERRORED
status.error = error_msg
break
context.affected_child_id = child_id
@when(
'a child plan completes successfully with changeset summary "{summary}"',
)
def step_child_plan_completes(context: Context, summary: str) -> None:
"""Simulate a child plan completion by updating the parent's subplan status."""
result: SpawnResult = context.spawn_result
parent: Plan = context.parent_plan
if result.child_plans:
child: Plan = result.child_plans[0]
child_id: str = child.identity.plan_id
# Update the matching subplan status on the parent
for status in parent.subplan_statuses:
if status.subplan_id == child_id:
status.status = ProcessingState.COMPLETE
status.changeset_summary = summary
status.files_changed = 1
break
context.affected_child_id = child_id
# -- Then steps ---------------------------------------------------------------
@then("each child Plan root_plan_id should trace to the root plan")
def step_child_plans_root_id(context: Context) -> None:
"""Assert child Plan objects reference the root plan."""
result: SpawnResult = context.spawn_result
assert result.child_plans is not None, "No child_plans on SpawnResult"
for child in result.child_plans:
assert isinstance(child, Plan)
assert child.identity.root_plan_id == _ROOT_PLAN_ID, (
f"Child plan root_plan_id={child.identity.root_plan_id!r}, "
f"expected {_ROOT_PLAN_ID!r}"
)
@then("each child plan should inherit the parent project links")
def step_child_plans_inherit_project_links(context: Context) -> None:
"""Assert child plans carry the parent's project links."""
result: SpawnResult = context.spawn_result
parent: Plan = context.parent_plan
assert result.child_plans is not None, "No child_plans on SpawnResult"
parent_project_names: list[str] = [
link.project_name for link in parent.project_links
]
for child in result.child_plans:
child_project_names: list[str] = [
link.project_name for link in child.project_links
]
assert child_project_names == parent_project_names, (
f"Child project links {child_project_names!r} do not match "
f"parent {parent_project_names!r}"
)
@then("the parent plan should have pending subplan statuses")
def step_parent_has_pending_statuses(context: Context) -> None:
"""Assert parent plan has queued subplan statuses after spawn."""
parent: Plan = context.parent_plan
assert len(parent.subplan_statuses) > 0, "No subplan statuses on parent"
for status in parent.subplan_statuses:
assert status.status == ProcessingState.QUEUED, (
f"Expected QUEUED, got {status.status!r}"
)
@then("the parent plan subplan_statuses count should equal spawn count")
def step_parent_status_count_matches(context: Context) -> None:
"""Assert the number of subplan statuses equals the spawn entries."""
parent: Plan = context.parent_plan
entries: list[SpawnEntry] = context.spawn_entries
assert len(parent.subplan_statuses) == len(entries), (
f"Parent has {len(parent.subplan_statuses)} statuses, expected {len(entries)}"
)
@then("the corresponding parent subplan status should be errored")
def step_parent_status_errored(context: Context) -> None:
"""Assert the affected child's parent subplan status is errored."""
parent: Plan = context.parent_plan
child_id: str = context.affected_child_id
matching: list[SubplanStatus] = [
s for s in parent.subplan_statuses if s.subplan_id == child_id
]
assert len(matching) == 1, f"Expected 1 matching status, got {len(matching)}"
assert matching[0].status == ProcessingState.ERRORED
@then('the parent subplan status error should contain "{fragment}"')
def step_parent_status_error_contains(context: Context, fragment: str) -> None:
"""Assert the error message contains the expected fragment."""
parent: Plan = context.parent_plan
child_id: str = context.affected_child_id
matching: list[SubplanStatus] = [
s for s in parent.subplan_statuses if s.subplan_id == child_id
]
assert len(matching) == 1
assert matching[0].error is not None, "Error is None"
assert fragment in matching[0].error, (
f"Expected '{fragment}' in error '{matching[0].error}'"
)
@then("the corresponding parent subplan status should be complete")
def step_parent_status_complete(context: Context) -> None:
"""Assert the affected child's parent subplan status is complete."""
parent: Plan = context.parent_plan
child_id: str = context.affected_child_id
matching: list[SubplanStatus] = [
s for s in parent.subplan_statuses if s.subplan_id == child_id
]
assert len(matching) == 1
assert matching[0].status == ProcessingState.COMPLETE
@then('the parent subplan status changeset summary should be "{summary}"')
def step_parent_status_changeset_summary(context: Context, summary: str) -> None:
"""Assert the changeset summary matches."""
parent: Plan = context.parent_plan
child_id: str = context.affected_child_id
matching: list[SubplanStatus] = [
s for s in parent.subplan_statuses if s.subplan_id == child_id
]
assert len(matching) == 1
assert matching[0].changeset_summary == summary, (
f"Expected summary '{summary}', got '{matching[0].changeset_summary}'"
)
@then("the spawn result execution mode should be parallel")
def step_result_mode_parallel(context: Context) -> None:
"""Assert the spawn result execution mode is parallel."""
result: SpawnResult = context.spawn_result
assert result.execution_mode == ExecutionMode.PARALLEL, (
f"Expected PARALLEL, got {result.execution_mode!r}"
)
@then("the spawn result child plan count should match entries")
def step_result_child_count_matches(context: Context) -> None:
"""Assert child plan count matches spawn entries."""
result: SpawnResult = context.spawn_result
entries: list[SpawnEntry] = context.spawn_entries
assert len(result.child_plans) == len(entries), (
f"Expected {len(entries)} child plans, got {len(result.child_plans)}"
)