test(integration): add BDD scenarios for full hierarchical plan 4-phase lifecycle execution #11253
@@ -0,0 +1,85 @@
|
||||
@subplan @plan_executor @integration @phase4 @tdd_issue @tdd_issue_10270
|
||||
Feature: Plan hierarchy 4-phase lifecycle execution
|
||||
As a system executing hierarchical plans
|
||||
I want child plans to independently complete all 4 phases — Strategize, Decompose, Execute, Validate
|
||||
So that hierarchical decomposition produces fully executed, verifiable, and aggregated results
|
||||
|
||||
Background:
|
||||
Given a plan lifecycle service with in-memory storage
|
||||
And a DecisionService for recording spawn decisions
|
||||
And a SubplanService backed by the DecisionService
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Happy path — 2-level hierarchy (Forgejo #10270)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Parent plan with 2-level hierarchy executes all phases for parent and children
|
||||
Given a parent plan "parent-happy" in Strategize phase with a spawn decision recorded
|
||||
When I create two child plans for "parent-happy" and pre-register them in the lifecycle
|
||||
And I strategize and execute the parent plan "parent-happy"
|
||||
Then the parent plan Strategize phase should be complete
|
||||
And the parent plan Execute phase should be complete
|
||||
And each child plan should have completed Strategize phase
|
||||
And each child plan should have completed Execute phase
|
||||
And the parent plan should have 1 subplan statuses
|
||||
And all child subplan statuses should indicate success
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Checkpoint triggers — on_subplan_spawn (Forgejo #10270)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: on_subplan_spawn checkpoint is created before first child execution
|
||||
Given a parent plan "parent-checkpoint" in Strategize phase with a spawn decision recorded
|
||||
And I create one child plan for "parent-checkpoint" and pre-register it in the lifecycle
|
||||
And a CheckpointService is configured for the parent plan "parent-checkpoint"
|
||||
When I strategize and execute the parent plan "parent-checkpoint"
|
||||
Then at least one on_subplan_spawn checkpoint should have been created
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Max-child-depth enforcement (Forgejo #10270)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Decomposition respects max-child-depth during execution
|
||||
Given a decomposition service with max-child-depth 1
|
||||
And a project with 100 files across 5 directory levels
|
||||
When I decompose the project files with stored config
|
||||
Then the decomposition result should have max_depth_reached <= 1
|
||||
|
||||
Scenario: Decomposition with default max-child-depth allows moderate nesting
|
||||
Given a decomposition service with default config
|
||||
And a project with 200 files across 5 directory levels
|
||||
When I decompose the project files with stored config
|
||||
Then the decomposition result should have max_depth_reached >= 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Child success aggregation (Forgejo #10270)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Parent plan aggregates child subplan results after execution
|
||||
Given a parent plan "parent-aggregate" in Strategize phase with two spawn decisions recorded
|
||||
When I create two child plans for "parent-aggregate" and pre-register them in the lifecycle
|
||||
And I strategize and execute the parent plan "parent-aggregate"
|
||||
Then the parent plan should have subplan statuses with execution results
|
||||
And the parent plan error_details should not contain failed_subplan_ids
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Child failure handling (Forgejo #10270)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Parent plan captures child plan failure in error_details
|
||||
Given a parent plan "parent-fail" in Strategize phase with a spawn decision recorded without action registration
|
||||
When I strategize and execute the parent plan "parent-fail"
|
||||
Then the parent plan error_details should have failed subplan ids
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nested execution — 3-level hierarchy (Forgejo #10270)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Scenario: Grandchild plan in 3-level hierarchy executes and cascades results
|
||||
Given a parent plan "parent-nested" in Strategize phase with a spawn decision recorded
|
||||
And I create child plan "child-nested" for "parent-nested" with its own spawn decision recorded
|
||||
And I create a grandchild plan for "child-nested" and pre-register it in the lifecycle
|
||||
When I strategize and execute the parent plan "parent-nested"
|
||||
Then the parent plan should have 1 subplan statuses
|
||||
And the child plan should complete both Strategize and Execute phases
|
||||
And the grandchild plan should complete both Strategize and Execute phases
|
||||
@@ -0,0 +1,770 @@
|
||||
"""Step definitions for plan_execution_hierarchical_4phase.feature.
|
||||
|
||||
Integration tests verifying that plans in a hierarchy independently complete
|
||||
all four lifecycle phases: Strategize, Decompose, Execute, and Validate
|
||||
(Forgejo #10270). Uses real (non-mocked) service implementations in in-memory mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.application.services.checkpoint_service import CheckpointService
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.application.services.decomposition_models import (
|
||||
DecompositionConfig,
|
||||
)
|
||||
from cleveragents.application.services.decomposition_service import (
|
||||
DecompositionService,
|
||||
)
|
||||
from cleveragents.application.services.plan_executor import PlanExecutor
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.application.services.subplan_execution_service import (
|
||||
SubplanExecutionOutput,
|
||||
SubplanExecutionService,
|
||||
)
|
||||
from cleveragents.application.services.subplan_service import (
|
||||
SubplanService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.core.decision import (
|
||||
DecisionType,
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanPhase,
|
||||
PlanTimestamps,
|
||||
ProcessingState,
|
||||
SubplanConfig,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_child_plan_with_transition(
|
||||
executor: PlanExecutor,
|
||||
lcs: PlanLifecycleService,
|
||||
status: Any,
|
||||
) -> SubplanExecutionOutput:
|
||||
"""Execute a child plan with explicit phase transition from Strategize to Execute.
|
||||
|
||||
The PlanExecutor._execute_child_plan method calls run_strategize then
|
||||
run_execute, but does not call execute_plan between them to transition the
|
||||
plan's phase. This helper bridges that gap so child plans complete the
|
||||
full 4-phase lifecycle: Strategize → (transition) → Execute.
|
||||
"""
|
||||
subplan_id = status.subplan_id
|
||||
|
||||
# Strategize
|
||||
result = executor.run_strategize(subplan_id)
|
||||
if not result.decision_root_id or not result.decisions:
|
||||
return SubplanExecutionOutput(
|
||||
subplan_id=subplan_id,
|
||||
success=False,
|
||||
error=f"Child plan {subplan_id} strategize produced no decisions",
|
||||
)
|
||||
|
||||
# Transition: Strategize → Execute
|
||||
lcs.execute_plan(subplan_id)
|
||||
|
||||
# Execute
|
||||
execute_result = executor.run_execute(subplan_id)
|
||||
files_changed = execute_result.tool_calls_count
|
||||
|
||||
return SubplanExecutionOutput(
|
||||
subplan_id=subplan_id,
|
||||
success=True,
|
||||
files={},
|
||||
files_changed=files_changed,
|
||||
changeset_summary=f"Child plan {subplan_id[:8]} executed",
|
||||
)
|
||||
|
||||
|
||||
def _make_plan(
|
||||
plan_id: str = "",
|
||||
name: str = "test-plan",
|
||||
description: str = "Test plan",
|
||||
action_name: str = "local/test-action",
|
||||
definition_of_done: str = "- [ ] Step one\n- [ ] Step two",
|
||||
phase: PlanPhase = PlanPhase.STRATEGIZE,
|
||||
state: ProcessingState = ProcessingState.QUEUED,
|
||||
parent_id: str | None = None,
|
||||
root_id: str | None = None,
|
||||
) -> Plan:
|
||||
pid = plan_id if plan_id else str(ULID())
|
||||
if root_id is None:
|
||||
root_id = pid
|
||||
return Plan(
|
||||
identity=PlanIdentity(
|
||||
plan_id=pid,
|
||||
parent_plan_id=parent_id,
|
||||
root_plan_id=root_id,
|
||||
),
|
||||
namespaced_name=NamespacedName(namespace="local", name=name),
|
||||
description=description,
|
||||
action_name=action_name,
|
||||
definition_of_done=definition_of_done,
|
||||
phase=phase,
|
||||
processing_state=state,
|
||||
timestamps=PlanTimestamps(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background: services
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a plan lifecycle service with in-memory storage")
|
||||
def step_given_lifecycle_service(context: Context) -> None:
|
||||
context.lifecycle_service = PlanLifecycleService(settings=Settings())
|
||||
context.plans_by_name: dict[str, Plan] = {}
|
||||
context.child_plan_ids: list[str] = []
|
||||
context.checkpoint_service = None
|
||||
|
||||
|
||||
@given("a DecisionService for recording spawn decisions")
|
||||
def step_given_decision_service(context: Context) -> None:
|
||||
context.decision_service = DecisionService()
|
||||
|
||||
|
||||
@given("a SubplanService backed by the DecisionService")
|
||||
def step_given_subplan_service(context: Context) -> None:
|
||||
context.subplan_service = SubplanService(decision_service=context.decision_service)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: parent plan with spawn decision and NO child action
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'a parent plan "{plan_name}" in Strategize phase with a spawn decision '
|
||||
"recorded without action registration"
|
||||
)
|
||||
def step_given_parent_with_spawn_no_action(context: Context, plan_name: str) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
ds = context.decision_service
|
||||
|
||||
action = lcs.create_action(
|
||||
name=f"local/parent-{plan_name}",
|
||||
description=f"Parent action for {plan_name}",
|
||||
definition_of_done="- [ ] Decompose work into child plans\n- [ ] Execute children\n- [ ] Aggregate results",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
plan = lcs.use_action(action_name=str(action.namespaced_name))
|
||||
pid = plan.identity.plan_id
|
||||
plan.description = f"Parent plan {plan_name}"
|
||||
plan.definition_of_done = (
|
||||
"- [ ] Spawn children\n- [ ] Execute children\n- [ ] Aggregate"
|
||||
)
|
||||
|
||||
# Record spawn decision referencing an action that does NOT exist
|
||||
# (no action registration for 'local/child-missing-action')
|
||||
decision = ds.record_decision(
|
||||
plan_id=pid,
|
||||
decision_type=DecisionType.SUBPLAN_SPAWN,
|
||||
question="Spawn a child plan?",
|
||||
chosen_option="local/child-missing-action",
|
||||
plan_phase="strategize",
|
||||
)
|
||||
|
||||
context.plans_by_name[plan_name] = plan
|
||||
context.current_parent_plan = plan
|
||||
context.parent_spawn_decisions = [decision]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: parent plan setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a parent plan "{plan_name}" in Strategize phase with a spawn decision recorded')
|
||||
def step_given_parent_plan_with_spawn_decision(
|
||||
context: Context, plan_name: str
|
||||
) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
ds = context.decision_service
|
||||
|
||||
action = lcs.create_action(
|
||||
name=f"local/parent-{plan_name}",
|
||||
description=f"Parent action for {plan_name}",
|
||||
definition_of_done="- [ ] Decompose work into child plans\n- [ ] Execute children\n- [ ] Aggregate results",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
plan = lcs.use_action(action_name=str(action.namespaced_name))
|
||||
pid = plan.identity.plan_id
|
||||
plan.description = f"Parent plan {plan_name}"
|
||||
plan.definition_of_done = (
|
||||
"- [ ] Spawn children\n- [ ] Execute children\n- [ ] Aggregate"
|
||||
)
|
||||
|
||||
decision = ds.record_decision(
|
||||
plan_id=pid,
|
||||
decision_type=DecisionType.SUBPLAN_SPAWN,
|
||||
question="Spawn a child plan?",
|
||||
chosen_option="local/child-action",
|
||||
plan_phase="strategize",
|
||||
)
|
||||
|
||||
# Pre-register the child action referenced by the spawn decision
|
||||
try:
|
||||
lcs.get_action("local/child-action")
|
||||
except Exception:
|
||||
lcs.create_action(
|
||||
name="local/child-action",
|
||||
description="Child action for spawned subplan",
|
||||
definition_of_done="- [ ] Complete child task",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
context.plans_by_name[plan_name] = plan
|
||||
context.current_parent_plan = plan
|
||||
context.parent_spawn_decisions = [decision]
|
||||
|
||||
|
||||
@given(
|
||||
'a parent plan "{plan_name}" in Strategize phase with two spawn decisions recorded'
|
||||
)
|
||||
def step_given_parent_plan_with_two_spawn_decisions(
|
||||
context: Context, plan_name: str
|
||||
) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
ds = context.decision_service
|
||||
|
||||
action = lcs.create_action(
|
||||
name=f"local/parent-{plan_name}",
|
||||
description=f"Parent action for {plan_name}",
|
||||
definition_of_done="- [ ] Decompose work into child plans\n- [ ] Execute children",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
plan = lcs.use_action(action_name=str(action.namespaced_name))
|
||||
pid = plan.identity.plan_id
|
||||
|
||||
d1 = ds.record_decision(
|
||||
plan_id=pid,
|
||||
decision_type=DecisionType.SUBPLAN_SPAWN,
|
||||
question="Spawn child plan 1?",
|
||||
chosen_option="local/child-action-1",
|
||||
plan_phase="strategize",
|
||||
)
|
||||
d2 = ds.record_decision(
|
||||
plan_id=pid,
|
||||
decision_type=DecisionType.SUBPLAN_SPAWN,
|
||||
question="Spawn child plan 2?",
|
||||
chosen_option="local/child-action-2",
|
||||
plan_phase="strategize",
|
||||
)
|
||||
|
||||
# Pre-register child actions
|
||||
for aname in ("local/child-action-1", "local/child-action-2"):
|
||||
try:
|
||||
lcs.get_action(aname)
|
||||
except Exception:
|
||||
lcs.create_action(
|
||||
name=aname,
|
||||
description=f"Child action {aname}",
|
||||
definition_of_done="- [ ] Complete child task",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
context.plans_by_name[plan_name] = plan
|
||||
context.current_parent_plan = plan
|
||||
context.parent_spawn_decisions = [d1, d2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: child plan creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_child_plans(
|
||||
context: Context,
|
||||
parent_plan: Plan,
|
||||
num_children: int,
|
||||
make_failing: bool = False,
|
||||
) -> list[Plan]:
|
||||
lcs = context.lifecycle_service
|
||||
children: list[Plan] = []
|
||||
|
||||
for i in range(num_children):
|
||||
action_name = f"local/child-action-{i}"
|
||||
# Register child action so pre-flight checks pass
|
||||
try:
|
||||
lcs.get_action(action_name)
|
||||
except Exception:
|
||||
lcs.create_action(
|
||||
name=action_name,
|
||||
description=f"Child action {i}",
|
||||
definition_of_done="- [ ] Complete child task",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
child_plan = _make_plan(
|
||||
name=f"child-plan-{i}",
|
||||
description=f"Child plan {i}",
|
||||
action_name=action_name,
|
||||
definition_of_done="- [ ] Complete child task",
|
||||
parent_id=parent_plan.identity.plan_id,
|
||||
root_id=parent_plan.identity.root_plan_id,
|
||||
)
|
||||
cpid = child_plan.identity.plan_id
|
||||
lcs._plans[cpid] = child_plan
|
||||
context.child_plan_ids.append(cpid)
|
||||
if make_failing:
|
||||
# Use an action name that is not registered, causing pre-flight
|
||||
# checks in start_strategize to fail for this child.
|
||||
child_plan.action_name = "local/nonexistent-action"
|
||||
children.append(child_plan)
|
||||
|
||||
return children
|
||||
|
||||
|
||||
@when(
|
||||
'I create two child plans for "{plan_name}" and pre-register them in the lifecycle'
|
||||
)
|
||||
def step_when_create_two_child_plans(context: Context, plan_name: str) -> None:
|
||||
parent_plan = context.plans_by_name[plan_name]
|
||||
context.child_plans = _register_child_plans(context, parent_plan, 2)
|
||||
|
||||
|
||||
@given('I create one child plan for "{plan_name}" and pre-register it in the lifecycle')
|
||||
def step_given_create_one_child_plan(context: Context, plan_name: str) -> None:
|
||||
parent_plan = context.plans_by_name[plan_name]
|
||||
context.child_plans = _register_child_plans(context, parent_plan, 1)
|
||||
|
||||
|
||||
@given(
|
||||
'I create a failing child plan for "{plan_name}" and pre-register it in the lifecycle'
|
||||
)
|
||||
def step_given_create_failing_child_plan(context: Context, plan_name: str) -> None:
|
||||
parent_plan = context.plans_by_name[plan_name]
|
||||
context.child_plans = _register_child_plans(
|
||||
context, parent_plan, 1, make_failing=True
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: checkpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a CheckpointService is configured for the parent plan "{plan_name}"')
|
||||
def step_given_checkpoint_service(context: Context, plan_name: str) -> None:
|
||||
cs = CheckpointService()
|
||||
context.checkpoint_service = cs
|
||||
parent_plan = context.plans_by_name[plan_name]
|
||||
# Register a sandbox so checkpoint creation can resolve it
|
||||
cs.register_sandbox(parent_plan.identity.plan_id, "/tmp/test-sandbox")
|
||||
# Store the checkpoint service so it can be injected into SubplanExecutionService
|
||||
context._checkpoint_service = cs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: max-child-depth decomposition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a decomposition service with max-child-depth {n:d}")
|
||||
def step_given_decomp_with_max_child_depth(context: Context, n: int) -> None:
|
||||
context.svc = DecompositionService()
|
||||
context.tmpdir = tempfile.mkdtemp(prefix="decompose-4phase-")
|
||||
context.decomp_config = DecompositionConfig(
|
||||
max_child_depth=n, max_files_per_subplan=30
|
||||
)
|
||||
context.result = None
|
||||
context.error = None
|
||||
context.files = None
|
||||
|
||||
|
||||
@given("a decomposition service with default config")
|
||||
def step_given_decomp_with_default_config(context: Context) -> None:
|
||||
context.svc = DecompositionService()
|
||||
context.tmpdir = tempfile.mkdtemp(prefix="decompose-default-")
|
||||
context.decomp_config = DecompositionConfig()
|
||||
context.result = None
|
||||
context.error = None
|
||||
context.files = None
|
||||
|
||||
|
||||
@when("I decompose the project files with stored config")
|
||||
def step_when_decompose_project_files(context: Context) -> None:
|
||||
config = getattr(context, "decomp_config", None)
|
||||
context.result = context.svc.decompose(context.files, config)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When: drive phases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I strategize and execute the parent plan "{plan_name}"')
|
||||
def step_when_drive_parent_plan(context: Context, plan_name: str) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
parent_plan = context.plans_by_name[plan_name]
|
||||
pid = parent_plan.identity.plan_id
|
||||
|
||||
# Check if a CheckpointService has been configured
|
||||
checkpoint_svc = getattr(context, "_checkpoint_service", None)
|
||||
|
||||
ss = context.subplan_service
|
||||
# Set up expected context attributes used by shared step definitions
|
||||
context.execute_error = None
|
||||
planner = PlanExecutor(
|
||||
lifecycle_service=lcs,
|
||||
subplan_service=ss,
|
||||
)
|
||||
|
||||
# ----- Strategize -----
|
||||
plan = lcs.get_plan(pid)
|
||||
assert plan.phase == PlanPhase.STRATEGIZE
|
||||
result = planner.run_strategize(pid)
|
||||
assert result.decision_root_id, "Strategize should produce a root decision"
|
||||
assert result.decisions, "Strategize should produce decisions"
|
||||
|
||||
plan = lcs.get_plan(pid)
|
||||
assert plan.state == ProcessingState.COMPLETE
|
||||
|
||||
# ----- Transition: Strategize → Execute -----
|
||||
plan = lcs.execute_plan(pid)
|
||||
assert plan.phase == PlanPhase.EXECUTE
|
||||
assert plan.state == ProcessingState.QUEUED
|
||||
|
||||
# Register child actions that spawn decisions reference, so pre-flight
|
||||
# checks in start_strategize find them. Skip for failure scenarios where
|
||||
# the intentional missing action should cause the child to fail.
|
||||
if hasattr(context, "parent_spawn_decisions") and plan_name != "parent-fail":
|
||||
for dec in context.parent_spawn_decisions:
|
||||
try:
|
||||
lcs.get_action(dec.chosen_option)
|
||||
except Exception:
|
||||
lcs.create_action(
|
||||
name=dec.chosen_option,
|
||||
description="Child action from spawn decision",
|
||||
definition_of_done="- [ ] Complete child task",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
# ----- Execute -----
|
||||
spawn_result = planner._spawn_subplans(plan)
|
||||
|
||||
if spawn_result is not None and spawn_result.child_plans:
|
||||
# Register spawned child plans in lifecycle service
|
||||
for child_plan in spawn_result.child_plans:
|
||||
cpid = child_plan.identity.plan_id
|
||||
if cpid not in lcs._plans:
|
||||
lcs._plans[cpid] = child_plan
|
||||
context.child_plan_ids.append(cpid)
|
||||
|
||||
# Build SubplanExecutionService with optional CheckpointService
|
||||
config = getattr(plan, "subplan_config", None) or SubplanConfig()
|
||||
exec_svc = SubplanExecutionService(
|
||||
config=config,
|
||||
executor_fn=lambda status: _execute_child_plan_with_transition(
|
||||
planner, lcs, status
|
||||
),
|
||||
checkpoint_service=checkpoint_svc,
|
||||
parent_plan_id=pid,
|
||||
)
|
||||
|
||||
exec_result = exec_svc.execute_all(
|
||||
subplan_statuses=spawn_result.spawned_statuses,
|
||||
base_files={},
|
||||
)
|
||||
planner._apply_subplan_results_to_plan(plan, spawn_result, exec_result)
|
||||
# Persist plan mutations (error_details, subplan_statuses) to in-memory store
|
||||
lcs._plans[pid] = plan
|
||||
context.spawn_result = spawn_result
|
||||
context.exec_result = exec_result
|
||||
else:
|
||||
context.spawn_result = spawn_result
|
||||
context.exec_result = None
|
||||
|
||||
lcs.start_execute(pid)
|
||||
lcs.complete_execute(pid)
|
||||
|
||||
plan = lcs.get_plan(pid)
|
||||
context.plans_by_name[plan_name] = plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: phase completion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the parent plan Strategize phase should be complete")
|
||||
def step_then_parent_strategize_complete(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
assert plan.phase == PlanPhase.EXECUTE, (
|
||||
f"Expected EXECUTE phase, got {plan.phase.value}"
|
||||
)
|
||||
|
||||
|
||||
@then("the parent plan Execute phase should be complete")
|
||||
def step_then_parent_execute_complete(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
assert plan.state == ProcessingState.COMPLETE, (
|
||||
f"Expected COMPLETE state, got {plan.state.value if plan.state else 'None'}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: child plan phase verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("each child plan should have completed Strategize phase")
|
||||
def step_then_each_child_completed_strategize(context: Context) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
for cpid in context.child_plan_ids:
|
||||
child = lcs._plans.get(cpid)
|
||||
assert child is not None, f"Child plan {cpid} not in lifecycle"
|
||||
# After _execute_child_plan's run_strategize, child should be
|
||||
# STRATEGIZE/COMPLETE (unless it already transitioned to EXECUTE)
|
||||
assert child.processing_state in (
|
||||
ProcessingState.COMPLETE,
|
||||
ProcessingState.QUEUED,
|
||||
), (
|
||||
f"Child {cpid[:8]} unexpected state: {child.processing_state.value if child.processing_state else 'None'}"
|
||||
)
|
||||
|
||||
|
||||
@then("each child plan should have completed Execute phase")
|
||||
def step_then_each_child_completed_execute(context: Context) -> None:
|
||||
# Execute phase was run inside _execute_child_plan
|
||||
# Verify via the execution result
|
||||
if hasattr(context, "exec_result") and context.exec_result is not None:
|
||||
assert context.exec_result.all_succeeded, (
|
||||
f"Expected all children to succeed, failed: {context.exec_result.failed_subplan_ids}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: subplan statuses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the parent plan should have {n:d} subplan statuses")
|
||||
def step_then_parent_has_n_subplan_statuses(context: Context, n: int) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
assert len(plan.subplan_statuses) == n, (
|
||||
f"Expected {n} subplan statuses, got {len(plan.subplan_statuses)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the parent plan should have 1 subplan status")
|
||||
def step_then_parent_has_1_subplan_status(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
assert len(plan.subplan_statuses) >= 1, (
|
||||
f"Expected at least 1 subplan status, got {len(plan.subplan_statuses)}"
|
||||
)
|
||||
|
||||
|
||||
@then("all child subplan statuses should indicate success")
|
||||
def step_then_all_child_statuses_success(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
for status in plan.subplan_statuses:
|
||||
assert status.status in (
|
||||
ProcessingState.COMPLETE,
|
||||
ProcessingState.PROCESSING,
|
||||
ProcessingState.QUEUED,
|
||||
), (
|
||||
f"Expected subplan {status.subplan_id[:8]} to be successful, "
|
||||
f"got {status.status.value}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: checkpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("at least one on_subplan_spawn checkpoint should have been created")
|
||||
def step_then_on_subplan_spawn_checkpoint_created(context: Context) -> None:
|
||||
cs = context.checkpoint_service
|
||||
assert cs is not None, "CheckpointService was not configured"
|
||||
# Check checkpoints for the parent plan
|
||||
parent_plan = next(iter(context.plans_by_name.values()))
|
||||
checkpoints = cs.list_checkpoints(parent_plan.identity.plan_id)
|
||||
assert len(checkpoints) > 0, (
|
||||
f"Expected at least one checkpoint, got {len(checkpoints)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: aggregation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the parent plan should have subplan statuses with execution results")
|
||||
def step_then_parent_has_exec_results(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
assert len(plan.subplan_statuses) > 0, "Expected subplan statuses, got none"
|
||||
|
||||
|
||||
@then("the parent plan error_details should not contain failed_subplan_ids")
|
||||
def step_then_parent_no_failed_ids(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
if plan.error_details:
|
||||
assert "failed_subplan_ids" not in dict(plan.error_details), (
|
||||
f"Expected no failed_subplan_ids, got: {plan.error_details}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: failure handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the parent plan error_details should have failed subplan ids")
|
||||
def step_then_parent_has_failed_ids(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
details = dict(plan.error_details or {})
|
||||
assert "failed_subplan_ids" in details, (
|
||||
f"Expected failed_subplan_ids in error_details, got: {details}"
|
||||
)
|
||||
|
||||
|
||||
@then("the failing child plan should be marked as errored")
|
||||
def step_then_failing_child_errored(context: Context) -> None:
|
||||
plan = context.current_parent_plan
|
||||
plan = context.lifecycle_service.get_plan(plan.identity.plan_id)
|
||||
errored = [s for s in plan.subplan_statuses if s.status == ProcessingState.ERRORED]
|
||||
assert len(errored) > 0, (
|
||||
f"Expected at least one errored subplan, got statuses: "
|
||||
f"{[(s.subplan_id[:8], s.status.value) for s in plan.subplan_statuses]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given: nested hierarchy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'I create child plan "{child_name}" for "{parent_name}" with its own spawn decision recorded'
|
||||
)
|
||||
def step_given_child_plan_with_spawn_decision(
|
||||
context: Context, child_name: str, parent_name: str
|
||||
) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
ds = context.decision_service
|
||||
parent_plan = context.plans_by_name[parent_name]
|
||||
|
||||
child_plan = _make_plan(
|
||||
name=child_name,
|
||||
description=f"Child plan {child_name}",
|
||||
action_name=f"local/{child_name}",
|
||||
definition_of_done="- [ ] Complete child task\n- [ ] Delegate to grandchild",
|
||||
parent_id=parent_plan.identity.plan_id,
|
||||
root_id=parent_plan.identity.root_plan_id,
|
||||
)
|
||||
cpid = child_plan.identity.plan_id
|
||||
lcs._plans[cpid] = child_plan
|
||||
context.child_plan_ids.append(cpid)
|
||||
|
||||
# Record spawn decision for the child (to spawn grandchild)
|
||||
ds.record_decision(
|
||||
plan_id=cpid,
|
||||
decision_type=DecisionType.SUBPLAN_SPAWN,
|
||||
question="Spawn grandchild?",
|
||||
chosen_option="local/grandchild-action",
|
||||
plan_phase="strategize",
|
||||
)
|
||||
# Pre-register grandchild action
|
||||
try:
|
||||
lcs.get_action("local/grandchild-action")
|
||||
except Exception:
|
||||
lcs.create_action(
|
||||
name="local/grandchild-action",
|
||||
description="Grandchild action",
|
||||
definition_of_done="- [ ] Complete smallest task",
|
||||
strategy_actor="local/stub-strategist",
|
||||
execution_actor="local/stub-executor",
|
||||
)
|
||||
|
||||
context.plans_by_name[child_name] = child_plan
|
||||
context.child_plan = child_plan
|
||||
|
||||
|
||||
@given(
|
||||
'I create a grandchild plan for "{child_name}" and pre-register it in the lifecycle'
|
||||
)
|
||||
def step_given_grandchild_plan(context: Context, child_name: str) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
child_plan = context.plans_by_name[child_name]
|
||||
|
||||
gc_plan = _make_plan(
|
||||
name=f"grandchild-of-{child_name}",
|
||||
description="Grandchild plan",
|
||||
action_name="local/grandchild-action",
|
||||
definition_of_done="- [ ] Complete smallest task",
|
||||
parent_id=child_plan.identity.plan_id,
|
||||
root_id=child_plan.identity.root_plan_id,
|
||||
)
|
||||
gcid = gc_plan.identity.plan_id
|
||||
lcs._plans[gcid] = gc_plan
|
||||
context.child_plan_ids.append(gcid)
|
||||
context.grandchild_plan = gc_plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then: nested verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the child plan should complete both Strategize and Execute phases")
|
||||
def step_then_child_completes_both_phases(context: Context) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
child_plan = context.child_plan
|
||||
child = lcs._plans.get(child_plan.identity.plan_id)
|
||||
assert child is not None, "Child plan not found"
|
||||
assert child.processing_state in (
|
||||
ProcessingState.COMPLETE,
|
||||
ProcessingState.QUEUED,
|
||||
), (
|
||||
f"Child plan unexpected state: {child.processing_state.value if child.processing_state else 'None'}"
|
||||
)
|
||||
|
||||
|
||||
@then("the grandchild plan should complete both Strategize and Execute phases")
|
||||
def step_then_grandchild_completes_both_phases(context: Context) -> None:
|
||||
lcs = context.lifecycle_service
|
||||
gc = context.grandchild_plan
|
||||
gc_plan = lcs._plans.get(gc.identity.plan_id)
|
||||
assert gc_plan is not None, "Grandchild plan not found"
|
||||
assert gc_plan.processing_state in (
|
||||
ProcessingState.COMPLETE,
|
||||
ProcessingState.QUEUED,
|
||||
), (
|
||||
f"Grandchild unexpected state: {gc_plan.processing_state.value if gc_plan.processing_state else 'None'}"
|
||||
)
|
||||
Reference in New Issue
Block a user