forked from HAL9000/cleveragents-core
b67dc63eda
Write Behave scenario and Robot Framework test proving that SubplanService.spawn() only creates metadata (SubplanStatus records and SpawnMetadata) without creating real child Plan domain objects or triggering lifecycle progression. Tests are tagged @tdd_expected_fail so CI passes via result inversion. ISSUES CLOSED: #838
251 lines
8.3 KiB
Python
251 lines
8.3 KiB
Python
"""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()
|