diff --git a/features/steps/tdd_subplan_spawn_orchestration_steps.py b/features/steps/tdd_subplan_spawn_orchestration_steps.py new file mode 100644 index 000000000..6ecfa7c72 --- /dev/null +++ b/features/steps/tdd_subplan_spawn_orchestration_steps.py @@ -0,0 +1,246 @@ +"""Step definitions for TDD Bug #823 — subplan spawn orchestration. + +These steps exercise ``SubplanService.spawn()`` and verify that it creates +real child ``Plan`` domain objects and triggers lifecycle progression. + +On ``master`` (before the fix), ``spawn()`` only creates ``SubplanStatus`` +records and ``SpawnMetadata`` without creating actual child ``Plan`` objects +or triggering the strategize phase. The assertions in these steps will +**fail** until the bug is fixed, proving the bug exists. +""" + +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, + SubplanConfig, +) + +_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() -> 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, + subplan_config=SubplanConfig( + execution_mode=ExecutionMode.SEQUENTIAL, + ), + ) + + +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("a parent plan configured for subplan spawning") +def step_parent_plan(context: Context) -> None: + """Set up a parent plan with subplan configuration.""" + context.parent_plan = _make_parent_plan() + context.subplan_service = SubplanService( + decision_service=_mock_decision_service(), + ) + context.subplan_config = SubplanConfig( + execution_mode=ExecutionMode.SEQUENTIAL, + ) + + +@given("valid spawn entries for the parent plan") +def step_spawn_entries(context: Context) -> None: + """Create valid spawn entries derived from decisions.""" + context.spawn_entries = _make_spawn_entries() + + +@when("I spawn subplans via SubplanService") +def step_spawn_subplans(context: Context) -> None: + """Call SubplanService.spawn() with the prepared inputs.""" + service: SubplanService = context.subplan_service + parent_plan: Plan = context.parent_plan + config: SubplanConfig = context.subplan_config + entries: list[SpawnEntry] = context.spawn_entries + + result: SpawnResult = service.spawn( + parent_plan=parent_plan, + config=config, + spawn_entries=entries, + ) + context.spawn_result = result + + +@then("the spawn result should contain child Plan domain objects") +def step_result_has_child_plans(context: Context) -> None: + """Assert that SpawnResult includes actual child Plan objects. + + Bug #823: SpawnResult only contains SubplanStatus metadata, not + real Plan domain objects. This assertion will fail until the bug + is fixed. + """ + result: SpawnResult = context.spawn_result + # The spawn result should have a way to access child Plan objects. + # Currently SpawnResult only has spawned_statuses (SubplanStatus) and + # metadata (SpawnMetadata) — no child Plan objects are created. + child_plans: object = getattr(result, "child_plans", None) + assert child_plans is not None, ( + "SpawnResult does not contain child_plans attribute — " + "spawn() only creates metadata without actual child Plan objects " + "(bug #823)" + ) + assert isinstance(child_plans, list), ( + "SpawnResult.child_plans should be a list of Plan objects" + ) + assert len(child_plans) == len(context.spawn_entries), ( + f"Expected {len(context.spawn_entries)} child plans, got {len(child_plans)}" + ) + + +@then("each child Plan should have parent_plan_id set to the parent") +def step_child_plans_have_parent_id(context: Context) -> None: + """Assert child Plan objects reference the parent plan. + + Bug #823: No child Plan objects are created, so this will fail. + """ + result: SpawnResult = context.spawn_result + child_plans: list[Plan] | None = getattr(result, "child_plans", None) + assert child_plans is not None, "No child_plans on SpawnResult (bug #823)" + for child in child_plans: + assert isinstance(child, Plan), ( + f"Expected Plan instance, got {type(child).__name__}" + ) + assert child.identity.parent_plan_id == _PARENT_PLAN_ID, ( + f"Child plan parent_plan_id={child.identity.parent_plan_id!r}, " + f"expected {_PARENT_PLAN_ID!r}" + ) + + +@then("each child plan should be in the strategize phase") +def step_child_plans_strategize(context: Context) -> None: + """Assert child plans enter the strategize phase after spawn. + + Bug #823: No child Plan objects exist to check phase on. + """ + result: SpawnResult = context.spawn_result + child_plans: list[Plan] | None = getattr(result, "child_plans", None) + assert child_plans is not None, ( + "No child_plans on SpawnResult — spawn() does not create " + "child Plan objects or trigger lifecycle (bug #823)" + ) + for child in child_plans: + assert child.phase == PlanPhase.STRATEGIZE, ( + f"Child plan phase={child.phase!r}, expected STRATEGIZE" + ) + + +@then("each child plan processing state should be queued") +def step_child_plans_queued(context: Context) -> None: + """Assert child plans start in QUEUED processing state. + + Bug #823: No child Plan objects exist. + """ + result: SpawnResult = context.spawn_result + child_plans: list[Plan] | None = getattr(result, "child_plans", None) + assert child_plans is not None, "No child_plans on SpawnResult (bug #823)" + for child in child_plans: + assert child.processing_state == ProcessingState.QUEUED, ( + f"Child plan state={child.processing_state!r}, expected QUEUED" + ) + + +@then("the parent plan subplan_statuses should reflect child lifecycle") +def step_parent_tracks_lifecycle(context: Context) -> None: + """Assert parent plan tracks child plan lifecycle progression. + + Bug #823: spawn() creates SubplanStatus records with status=QUEUED + but does not update them as child plans progress. Even the basic + expectation that SubplanStatus entries are attached to the parent + plan (not just returned in SpawnResult) is not met. + """ + parent: Plan = context.parent_plan + result: SpawnResult = context.spawn_result + # After spawn, the parent plan's subplan_statuses should be updated + # to include the newly spawned child plans. + assert len(parent.subplan_statuses) == len(context.spawn_entries), ( + f"Parent plan has {len(parent.subplan_statuses)} subplan_statuses " + f"but {len(context.spawn_entries)} were spawned — spawn() does not " + f"attach statuses to the parent plan (bug #823). " + f"Statuses are only returned in SpawnResult.spawned_statuses " + f"({result.total_spawned} entries)." + ) + + +@then("the spawn result total_spawned should match child plan count") +def step_total_spawned_matches(context: Context) -> None: + """Assert total_spawned reflects actual child Plan objects. + + Bug #823: total_spawned counts SubplanStatus records, not real + child Plan objects. This step verifies the child_plans list exists + and its length matches total_spawned. + """ + result: SpawnResult = context.spawn_result + child_plans: list[Plan] | None = getattr(result, "child_plans", None) + assert child_plans is not None, ( + "No child_plans on SpawnResult — cannot verify count (bug #823)" + ) + assert len(child_plans) == result.total_spawned, ( + f"child_plans count ({len(child_plans)}) does not match " + f"total_spawned ({result.total_spawned})" + ) diff --git a/features/tdd_subplan_spawn_orchestration.feature b/features/tdd_subplan_spawn_orchestration.feature new file mode 100644 index 000000000..d3c23c396 --- /dev/null +++ b/features/tdd_subplan_spawn_orchestration.feature @@ -0,0 +1,34 @@ +@tdd_expected_fail @tdd_bug @tdd_bug_823 @mock_only +Feature: TDD Bug #823 — subplan spawn creates metadata but does not orchestrate child plan execution + As a developer + I want to verify that SubplanService.spawn() creates real child Plan + domain objects and triggers their lifecycle progression + So that the bug is captured and will be caught by a regression test + + SubplanService.spawn() currently creates SubplanStatus records and + SpawnMetadata but does NOT create actual child Plan domain objects, + trigger the strategize phase on child plans, set up inter-plan + communication, merge results, or handle child plan failures. These + tests assert the expected behaviour and will FAIL until the bug is + fixed. The @tdd_expected_fail tag inverts the result so CI passes. + + Scenario: Spawn result contains child Plan domain objects + Given a parent plan configured for subplan spawning + And valid spawn entries for the parent plan + When I spawn subplans via SubplanService + Then the spawn result should contain child Plan domain objects + And each child Plan should have parent_plan_id set to the parent + + Scenario: Child plans enter the strategize phase after spawn + Given a parent plan configured for subplan spawning + And valid spawn entries for the parent plan + When I spawn subplans via SubplanService + Then each child plan should be in the strategize phase + And each child plan processing state should be queued + + Scenario: Parent plan tracks child plan lifecycle status + Given a parent plan configured for subplan spawning + And valid spawn entries for the parent plan + When I spawn subplans via SubplanService + Then the parent plan subplan_statuses should reflect child lifecycle + And the spawn result total_spawned should match child plan count diff --git a/robot/helper_tdd_subplan_spawn_orchestration.py b/robot/helper_tdd_subplan_spawn_orchestration.py new file mode 100644 index 000000000..e7ca7db8c --- /dev/null +++ b/robot/helper_tdd_subplan_spawn_orchestration.py @@ -0,0 +1,250 @@ +"""Helper script for tdd_subplan_spawn_orchestration.robot smoke tests. + +Each subcommand exercises SubplanService.spawn() to reproduce bug #823. +The helper reports the **real** outcome: it exits 0 and prints the sentinel +when the expected behaviour is observed (bug fixed), and exits 1 when the +bug is still present. The ``tdd_expected_fail_listener`` on the Robot side +handles pass/fail inversion while the bug remains open. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from pathlib import Path +from typing import NoReturn +from unittest.mock import MagicMock + +# Ensure local source tree is importable. +_ROOT: Path = Path(__file__).resolve().parents[1] +_SRC: str = str(_ROOT / "src") +_ROBOT: str = str(_ROOT / "robot") +for _p in (_SRC, _ROBOT): + if _p not in sys.path: + sys.path.insert(0, _p) + +from cleveragents.application.services.subplan_service import ( # noqa: E402 + SpawnEntry, + SpawnResult, + SubplanService, +) +from cleveragents.domain.models.core.decision import ( # noqa: E402 + ContextSnapshot, + Decision, + DecisionType, +) +from cleveragents.domain.models.core.plan import ( # noqa: E402 + ExecutionMode, + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + ProcessingState, + SubplanConfig, +) + +_PARENT_PLAN_ID: str = "01HGZ6FE0AQDYTR4BXVQZ6PN00" +_ROOT_PLAN_ID: str = "01HGZ6FE0AQDYTR4BXVQZ6RF00" +_DEC_ID1: str = "01HGZ6FE0AQDYTR4BXVQZ6DA00" +_DEC_ID2: str = "01HGZ6FE0AQDYTR4BXVQZ6DB00" + + +def _fail(msg: str) -> NoReturn: + """Print error message to stderr and exit with code 1.""" + print(msg, file=sys.stderr) + sys.exit(1) + + +def _mock_decision_service() -> MagicMock: + """Create a mock DecisionService.""" + svc: MagicMock = MagicMock() + svc.list_by_type = MagicMock(return_value=[]) + return svc + + +def _make_parent_plan() -> 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, + subplan_config=SubplanConfig( + execution_mode=ExecutionMode.SEQUENTIAL, + ), + ) + + +def _make_spawn_entries() -> list[SpawnEntry]: + """Create spawn entries for testing.""" + dec1: Decision = Decision( + decision_id=_DEC_ID1, + 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=_DEC_ID2, + 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"), + ] + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def _spawn_child_plans() -> None: + """Verify spawn() returns actual child Plan domain objects. + + Bug #823: SpawnResult only has spawned_statuses (SubplanStatus) and + metadata (SpawnMetadata). No child_plans attribute exists. + """ + svc: SubplanService = SubplanService( + decision_service=_mock_decision_service(), + ) + parent: Plan = _make_parent_plan() + config: SubplanConfig = SubplanConfig( + execution_mode=ExecutionMode.SEQUENTIAL, + ) + entries: list[SpawnEntry] = _make_spawn_entries() + + result: SpawnResult = svc.spawn( + parent_plan=parent, + config=config, + spawn_entries=entries, + ) + + # Assert that SpawnResult contains child Plan objects + child_plans: object = getattr(result, "child_plans", None) + if child_plans is None: + _fail( + "SpawnResult does not contain child_plans attribute — " + "spawn() only creates metadata without actual child Plan " + "objects (bug #823)" + ) + if not isinstance(child_plans, list): + _fail("SpawnResult.child_plans should be a list of Plan objects") + if len(child_plans) != len(entries): + _fail(f"Expected {len(entries)} child plans, got {len(child_plans)}") + # Verify each child has correct parent_plan_id + for child in child_plans: + if not isinstance(child, Plan): + _fail(f"Expected Plan instance, got {type(child).__name__}") + if child.identity.parent_plan_id != _PARENT_PLAN_ID: + _fail( + f"Child plan parent_plan_id={child.identity.parent_plan_id!r}, " + f"expected {_PARENT_PLAN_ID!r}" + ) + print("tdd-spawn-child-plans-ok") + + +def _spawn_lifecycle() -> None: + """Verify child plans enter the strategize phase after spawn. + + Bug #823: No child Plan objects are created, so lifecycle is not + triggered. + """ + svc: SubplanService = SubplanService( + decision_service=_mock_decision_service(), + ) + parent: Plan = _make_parent_plan() + config: SubplanConfig = SubplanConfig( + execution_mode=ExecutionMode.SEQUENTIAL, + ) + entries: list[SpawnEntry] = _make_spawn_entries() + + result: SpawnResult = svc.spawn( + parent_plan=parent, + config=config, + spawn_entries=entries, + ) + + # Assert that child plans exist and are in strategize phase + child_plans: object = getattr(result, "child_plans", None) + if child_plans is None: + _fail( + "No child_plans on SpawnResult — spawn() does not create " + "child Plan objects or trigger lifecycle (bug #823)" + ) + if not isinstance(child_plans, list): + _fail("child_plans is not a list") + for child in child_plans: + if not isinstance(child, Plan): + _fail(f"Expected Plan, got {type(child).__name__}") + if child.phase != PlanPhase.STRATEGIZE: + _fail(f"Child plan phase={child.phase!r}, expected STRATEGIZE") + if child.processing_state != ProcessingState.QUEUED: + _fail(f"Child plan state={child.processing_state!r}, expected QUEUED") + print("tdd-spawn-lifecycle-ok") + + +def _spawn_parent_tracking() -> None: + """Verify parent plan tracks spawned child plan statuses. + + Bug #823: spawn() creates SubplanStatus records in SpawnResult but + does not attach them to the parent plan's subplan_statuses list. + """ + svc: SubplanService = SubplanService( + decision_service=_mock_decision_service(), + ) + parent: Plan = _make_parent_plan() + config: SubplanConfig = SubplanConfig( + execution_mode=ExecutionMode.SEQUENTIAL, + ) + entries: list[SpawnEntry] = _make_spawn_entries() + + result: SpawnResult = svc.spawn( + parent_plan=parent, + config=config, + spawn_entries=entries, + ) + + # After spawn(), parent plan's subplan_statuses should be populated + if len(parent.subplan_statuses) != len(entries): + _fail( + f"Parent plan has {len(parent.subplan_statuses)} subplan_statuses " + f"but {len(entries)} were spawned — spawn() does not attach " + f"statuses to the parent plan (bug #823). " + f"Statuses only in SpawnResult.spawned_statuses " + f"({result.total_spawned} entries)." + ) + print("tdd-spawn-parent-tracking-ok") + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + +_COMMANDS: dict[str, Callable[[], None]] = { + "spawn-child-plans": _spawn_child_plans, + "spawn-lifecycle": _spawn_lifecycle, + "spawn-parent-tracking": _spawn_parent_tracking, +} + +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) + cmd: Callable[[], None] = _COMMANDS[sys.argv[1]] + cmd() diff --git a/robot/tdd_subplan_spawn_orchestration.robot b/robot/tdd_subplan_spawn_orchestration.robot new file mode 100644 index 000000000..b7adfc12b --- /dev/null +++ b/robot/tdd_subplan_spawn_orchestration.robot @@ -0,0 +1,44 @@ +*** Settings *** +Documentation TDD Bug #823 — subplan spawn creates metadata but does not orchestrate +... child plan execution. Integration smoke tests verifying that +... SubplanService.spawn() creates real child Plan domain objects and +... triggers lifecycle progression. Currently spawn() only creates +... SubplanStatus records and SpawnMetadata without creating child Plans. +... Tests are tagged tdd_expected_fail so CI passes via result inversion. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_tdd_subplan_spawn_orchestration.py + +*** Test Cases *** +TDD Spawn Result Contains Child Plan Objects + [Documentation] Verify that spawn() returns actual child Plan domain objects, + ... not just SubplanStatus metadata. + [Tags] tdd_expected_fail tdd_bug tdd_bug_823 + ${result}= Run Process ${PYTHON} ${HELPER} spawn-child-plans cwd=${WORKSPACE} timeout=30s + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tdd-spawn-child-plans-ok + +TDD Child Plans Enter Strategize Phase + [Documentation] Verify that spawned child plans enter the strategize phase + ... and have their lifecycle triggered. + [Tags] tdd_expected_fail tdd_bug tdd_bug_823 + ${result}= Run Process ${PYTHON} ${HELPER} spawn-lifecycle cwd=${WORKSPACE} timeout=30s + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tdd-spawn-lifecycle-ok + +TDD Parent Plan Tracks Child Status + [Documentation] Verify that spawn() attaches SubplanStatus entries to + ... the parent plan so lifecycle can be tracked. + [Tags] tdd_expected_fail tdd_bug tdd_bug_823 + ${result}= Run Process ${PYTHON} ${HELPER} spawn-parent-tracking cwd=${WORKSPACE} timeout=30s + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tdd-spawn-parent-tracking-ok