forked from HAL9000/cleveragents-core
21e9a65c33
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
285 lines
10 KiB
Python
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)}"
|
|
)
|