"""Step definitions for subplan execution and merge scenarios.""" from __future__ import annotations import threading import time from collections.abc import Callable from behave import given, then, when from behave.runner import Context from cleveragents.application.services.subplan_execution_service import ( SubplanExecutionOutput, SubplanExecutionResult, SubplanExecutionService, ) from cleveragents.application.services.subplan_merge_service import ( MergeConflictError, SubplanMergeResult, SubplanMergeService, ) from cleveragents.domain.models.core.plan import ( ExecutionMode, ProcessingState, SubplanConfig, SubplanMergeStrategy, SubplanStatus, ) _S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00" _S2 = "01HGZ6FE0AQDYTR4BXVQZ6EB00" _S3 = "01HGZ6FE0AQDYTR4BXVQZ6EC00" _S4 = "01HGZ6FE0AQDYTR4BXVQZ6ED00" _S5 = "01HGZ6FE0AQDYTR4BXVQZ6EE00" _SEEDED_IDS = (_S1, _S2, _S3, _S4, _S5) def _ids_for_count(n: int) -> list[str]: """Return *n* deterministic ULID-like identifiers. Preserves legacy fixed IDs for the first five subplans so existing step assertions that reference ``_S1``/``_S2``/``_S3`` continue to work, and deterministically generates additional IDs for scale scenarios. """ if n <= len(_SEEDED_IDS): return list(_SEEDED_IDS[:n]) generated = list(_SEEDED_IDS) for idx in range(len(_SEEDED_IDS), n): generated.append(f"01HGZ6FE0AQDYTR4BXVQ{idx:06d}") return generated def _make_status( subplan_id: str, action: str = "local/sub-action", ) -> SubplanStatus: return SubplanStatus( subplan_id=subplan_id, action_name=action, ) def _success_executor(status: SubplanStatus) -> SubplanExecutionOutput: return SubplanExecutionOutput( subplan_id=status.subplan_id, success=True, files={"src/main.py": f"# modified by {status.subplan_id}\n"}, files_changed=1, changeset_summary="Modified main.py", ) def _ordered_executor( order_list: list[str], lock: threading.Lock, ) -> Callable[[SubplanStatus], SubplanExecutionOutput]: def _exec(status: SubplanStatus) -> SubplanExecutionOutput: with lock: order_list.append(status.subplan_id) # Use the un-patched sleep so that ordering/timing tests see # real wall-clock delays rather than the global 10ms cap. _real_sleep = getattr(time, "_original_sleep", time.sleep) _real_sleep(0.01) return SubplanExecutionOutput( subplan_id=status.subplan_id, success=True, files={"src/main.py": f"# by {status.subplan_id}\n"}, files_changed=1, ) return _exec # --- Sequential execution --- @given("a parent plan with {n:d} subplans in sequential mode") def step_parent_sequential(context: Context, n: int) -> None: ids = _ids_for_count(n) context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL) context.executor_fn = _success_executor context.fail_map: dict[str, str | None] = {} context.retry_map: dict[str, int] = {} context.execution_order: list[str] = [] context.order_lock = threading.Lock() @given("a parent plan with {n:d} subplans in sequential mode with fail_fast") def step_parent_sequential_failfast(context: Context, n: int) -> None: ids = _ids_for_count(n) context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, fail_fast=True, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("a parent plan with {n:d} subplan in sequential mode with retry enabled") def step_parent_sequential_retry(context: Context, n: int) -> None: ids = _ids_for_count(n) context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, retry_failed=True, max_retries=2, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given('the second subplan will fail with "{error}"') def step_second_fails(context: Context, error: str) -> None: context.fail_map[_S2] = error @given('the subplan will fail once with "{error}" then succeed') def step_fail_once_then_succeed(context: Context, error: str) -> None: context.fail_map[_S1] = error context.retry_map[_S1] = 1 # fail once, succeed on retry @given('the first subplan will fail with "{error}"') def step_first_fails(context: Context, error: str) -> None: context.fail_map[_S1] = error # --- Parallel execution --- @given("a parent plan with {n:d} subplans in parallel mode with max_parallel {mp:d}") def step_parent_parallel(context: Context, n: int, mp: int) -> None: ids = _ids_for_count(n) context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=mp, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("a parent plan with {n:d} subplans in parallel mode with fail_fast") def step_parent_parallel_failfast(context: Context, n: int) -> None: ids = _ids_for_count(n) context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=n, fail_fast=True, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() # --- Dependency-ordered execution --- @given("a parent plan with {n:d} subplans in dependency_ordered mode") def step_parent_dependency(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.DEPENDENCY_ORDERED, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() context.dependency_graph: dict[str, list[str]] = {} @given("subplan C depends on subplan B which depends on subplan A") def step_linear_deps(context: Context) -> None: context.dependency_graph = { _S1: [], _S2: [_S1], _S3: [_S2], } @given("the subplans have circular dependencies") def step_circular_deps(context: Context) -> None: context.dependency_graph = { _S1: [_S2], _S2: [_S1], } # --- When: execution --- def _build_executor( context: Context, ) -> Callable[[SubplanStatus], SubplanExecutionOutput]: call_counts: dict[str, int] = {} def _exec(status: SubplanStatus) -> SubplanExecutionOutput: sid = status.subplan_id call_counts[sid] = call_counts.get(sid, 0) + 1 with context.order_lock: context.execution_order.append(sid) # Track concurrency when configured concurrency: dict[str, int] | None = getattr( context, "concurrency_counter", None ) concurrency_lock: threading.Lock | None = getattr( context, "concurrency_lock", None ) if concurrency is not None and concurrency_lock is not None: with concurrency_lock: concurrency["current"] += 1 concurrency["max"] = max(concurrency["max"], concurrency["current"]) try: # Check if executor should raise an exception raise_map: dict[str, str] = getattr(context, "raise_map", {}) raise_counts: dict[str, int] = getattr(context, "raise_counts", {}) if sid in raise_map and call_counts[sid] <= raise_counts.get(sid, 0): error_type = raise_map[sid] raise RuntimeError(f"{error_type}: simulated executor exception") # Check retry logic max_fail = context.retry_map.get(sid, 0) if sid in context.fail_map and call_counts[sid] <= max_fail: error = context.fail_map[sid] return SubplanExecutionOutput( subplan_id=sid, success=False, error=error, ) if sid in context.fail_map and sid not in context.retry_map: error = context.fail_map[sid] return SubplanExecutionOutput( subplan_id=sid, success=False, error=error, ) # Per-subplan delay (supports staggered completion / timeout tests). # Use the un-patched sleep so that timeout scenarios see real # wall-clock delays rather than the global 10ms cap. _real_sleep = getattr(time, "_original_sleep", time.sleep) delay_map: dict[str, float] = getattr(context, "delay_map", {}) delay = delay_map.get(sid, 0.01) _real_sleep(delay) override: dict[str, dict[str, str]] = getattr(context, "override_files", {}) if sid in override: files = override[sid] else: files = {f"src/{sid[-4:]}.py": f"# modified by {sid}\n"} return SubplanExecutionOutput( subplan_id=sid, success=True, files=files, files_changed=len(files), changeset_summary=f"Modified by {sid}", ) finally: if concurrency is not None and concurrency_lock is not None: with concurrency_lock: concurrency["current"] -= 1 return _exec @when("the subplans are executed") def step_execute_subplans(context: Context) -> None: executor_fn = _build_executor(context) service = SubplanExecutionService( config=context.subplan_cfg, executor_fn=executor_fn, ) dep_graph = getattr(context, "dependency_graph", None) base_files: dict[str, str] = getattr(context, "base_files_for_exec", {}) context.exec_result = service.execute_all( subplan_statuses=context.subplan_statuses, base_files=base_files, dependency_graph=dep_graph, ) @given("parallel execution concurrency tracking is enabled") def step_parallel_tracking_enabled(context: Context) -> None: context.concurrency_counter = {"current": 0, "max": 0} context.concurrency_lock = threading.Lock() @when("the subplans are executed expecting an error") def step_execute_expecting_error(context: Context) -> None: executor_fn = _build_executor(context) service = SubplanExecutionService( config=context.subplan_cfg, executor_fn=executor_fn, ) dep_graph = getattr(context, "dependency_graph", None) try: context.exec_result = service.execute_all( subplan_statuses=context.subplan_statuses, base_files={}, dependency_graph=dep_graph, ) context.exec_error = None except Exception as exc: context.exec_error = exc context.exec_result = None @when("the subplans are executed without a dependency graph") def step_execute_no_dep_graph(context: Context) -> None: executor_fn = _build_executor(context) service = SubplanExecutionService( config=context.subplan_cfg, executor_fn=executor_fn, ) try: context.exec_result = service.execute_all( subplan_statuses=context.subplan_statuses, base_files={}, dependency_graph=None, ) context.exec_error = None except Exception as exc: context.exec_error = exc context.exec_result = None # --- Then: execution assertions --- @then("all {n:d} subplans should complete successfully") def step_all_succeeded(context: Context, n: int) -> None: result: SubplanExecutionResult = context.exec_result completed = [s for s in result.statuses if s.status == ProcessingState.COMPLETE] assert len(completed) == n, ( f"Expected {n} completed, got {len(completed)}: " f"{[(s.subplan_id, s.status) for s in result.statuses]}" ) @then("the subplans should have been executed in order") def step_executed_in_order(context: Context) -> None: expected = [s.subplan_id for s in context.subplan_statuses] # Check the execution_order list actual = context.execution_order # Sequential: first execution of each should be in order seen: list[str] = [] for sid in actual: if sid not in seen: seen.append(sid) assert seen == expected, f"Expected order {expected}, got {seen}" @then("the execution result should report all succeeded") def step_result_all_succeeded(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert result.all_succeeded, ( f"Expected all succeeded but got failures: {result.failed_subplan_ids}" ) @then("the first subplan should complete successfully") def step_first_complete(context: Context) -> None: result: SubplanExecutionResult = context.exec_result first = result.statuses[0] assert first.status == ProcessingState.COMPLETE, ( f"Expected COMPLETE, got {first.status}" ) @then("the first subplan should be errored") def step_first_errored(context: Context) -> None: result: SubplanExecutionResult = context.exec_result first = result.statuses[0] assert first.status == ProcessingState.ERRORED, ( f"Expected ERRORED, got {first.status}" ) @then("the second subplan should be errored") def step_second_errored(context: Context) -> None: result: SubplanExecutionResult = context.exec_result second = result.statuses[1] assert second.status == ProcessingState.ERRORED, ( f"Expected ERRORED, got {second.status}" ) @then("the third subplan should be cancelled") def step_third_cancelled(context: Context) -> None: result: SubplanExecutionResult = context.exec_result third = result.statuses[2] assert third.status == ProcessingState.CANCELLED, ( f"Expected CANCELLED, got {third.status}" ) @then("the subplan should complete successfully") def step_single_complete(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert result.statuses[0].status == ProcessingState.COMPLETE @then("the subplan should have {n:d} previous attempt recorded") def step_previous_attempts(context: Context, n: int) -> None: result: SubplanExecutionResult = context.exec_result status = result.statuses[0] assert len(status.previous_attempts) == n, ( f"Expected {n} previous attempts, got {len(status.previous_attempts)}" ) @then("at least one subplan should be errored") def step_at_least_one_errored(context: Context) -> None: result: SubplanExecutionResult = context.exec_result errored = [s for s in result.statuses if s.status == ProcessingState.ERRORED] assert len(errored) >= 1, "Expected at least one errored subplan" @then("the execution result should have failed subplan ids") def step_has_failed_ids(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert len(result.failed_subplan_ids) > 0 @then("the peak concurrent execution count should not exceed {n:d}") def step_peak_concurrency_not_exceed(context: Context, n: int) -> None: peak = context.concurrency_counter["max"] assert peak <= n, f"Expected peak concurrency <= {n}, got {peak}" @then("subplan A should have completed before subplan B") def step_a_before_b(context: Context) -> None: order = context.execution_order first_a = next(i for i, sid in enumerate(order) if sid == _S1) first_b = next(i for i, sid in enumerate(order) if sid == _S2) assert first_a < first_b, f"A at {first_a}, B at {first_b}" @then("subplan B should have completed before subplan C") def step_b_before_c(context: Context) -> None: order = context.execution_order first_b = next(i for i, sid in enumerate(order) if sid == _S2) first_c = next(i for i, sid in enumerate(order) if sid == _S3) assert first_b < first_c, f"B at {first_b}, C at {first_c}" @then("a circular dependency error should be raised") def step_circular_error(context: Context) -> None: assert context.exec_error is not None assert "Circular dependency" in str(context.exec_error) @then("a missing dependency graph error should be raised") def step_missing_graph_error(context: Context) -> None: assert context.exec_error is not None assert "dependency_graph is required" in str(context.exec_error) # --- Merge strategy steps --- @given("subplan outputs with non-overlapping changes to the same file") def step_non_overlapping_outputs(context: Context) -> None: context.base_files = {"src/main.py": "line1\nline2\nline3\n"} context.subplan_outputs = [ (_S1, {"src/main.py": "line1\nline2\nline3\nnew_line_a\n"}), (_S2, {"src/main.py": "new_line_b\nline1\nline2\nline3\n"}), ] @given("subplan outputs with overlapping changes to the same file") def step_overlapping_outputs(context: Context) -> None: context.base_files = {"src/main.py": "original content\n"} context.subplan_outputs = [ (_S1, {"src/main.py": "first version\n"}), (_S2, {"src/main.py": "second version\n"}), ] @given("subplan outputs with conflicting changes to the same file") def step_conflicting_outputs(context: Context) -> None: context.base_files = {"src/main.py": "base line\n"} context.subplan_outputs = [ (_S1, {"src/main.py": "alpha change\n"}), (_S2, {"src/main.py": "beta change\n"}), ] @given("a single subplan output with file changes") def step_single_output(context: Context) -> None: context.base_files = {"src/main.py": "original\n"} context.subplan_outputs = [ (_S1, {"src/main.py": "modified by subplan\n"}), ] @given("no subplan outputs") def step_no_outputs(context: Context) -> None: context.base_files = {} context.subplan_outputs = [] @given("the merge strategy is {strategy}") def step_merge_strategy(context: Context, strategy: str) -> None: strategy_map = { "git_three_way": SubplanMergeStrategy.GIT_THREE_WAY, "sequential_apply": SubplanMergeStrategy.SEQUENTIAL_APPLY, "fail_on_conflict": SubplanMergeStrategy.FAIL_ON_CONFLICT, "last_wins": SubplanMergeStrategy.LAST_WINS, } context.merge_strategy = strategy_map[strategy] @when("the subplan outputs are merged") def step_merge_outputs(context: Context) -> None: service = SubplanMergeService(context.merge_strategy) context.merge_result = service.merge(context.base_files, context.subplan_outputs) @when("the subplan outputs are merged expecting a conflict error") def step_merge_expecting_conflict(context: Context) -> None: service = SubplanMergeService(context.merge_strategy) try: context.merge_result = service.merge( context.base_files, context.subplan_outputs ) context.merge_error = None except MergeConflictError as exc: context.merge_error = exc context.merge_result = None @when("the subplan outputs are merged expecting a validation error") def step_merge_expecting_validation(context: Context) -> None: service = SubplanMergeService(context.merge_strategy) try: context.merge_result = service.merge( context.base_files, context.subplan_outputs ) context.merge_error = None except ValueError as exc: context.merge_error = exc context.merge_result = None @then("the merge should succeed") def step_merge_succeed(context: Context) -> None: result: SubplanMergeResult = context.merge_result assert result.success, f"Merge failed with conflicts: {result.conflict_files}" @then("the merged file should contain changes from both subplans") def step_merged_both_changes(context: Context) -> None: result: SubplanMergeResult = context.merge_result content = result.merged_files[0].content # Non-overlapping changes: should contain the new lines from both assert "new_line_a" in content and "new_line_b" in content, ( f"Expected content from both subplans, got: {content}" ) @then("the final content should reflect sequential application") def step_sequential_content(context: Context) -> None: result: SubplanMergeResult = context.merge_result content = result.merged_files[0].content # Sequential apply: last subplan's content wins assert "second version" in content, f"Expected second version, got: {content}" @then("a merge conflict error should be raised") def step_conflict_error_raised(context: Context) -> None: assert context.merge_error is not None assert isinstance(context.merge_error, MergeConflictError) @then("the merged content should be from the last subplan") def step_last_wins_content(context: Context) -> None: result: SubplanMergeResult = context.merge_result content = result.merged_files[0].content assert "second version" in content, f"Expected last wins, got: {content}" @then("the merged content should match the single subplan output") def step_single_output_match(context: Context) -> None: result: SubplanMergeResult = context.merge_result assert result.merged_files[0].content == "modified by subplan\n" @then("an empty outputs error should be raised") def step_empty_outputs_error(context: Context) -> None: assert context.merge_error is not None assert "must not be empty" in str(context.merge_error) # --- Integration steps --- @given("a parent plan with {n:d} subplans in sequential mode with git merge") def step_parent_seq_git(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, merge_strategy=SubplanMergeStrategy.GIT_THREE_WAY, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("both subplans produce non-overlapping file changes") def step_non_overlapping_changes(context: Context) -> None: context.base_files_for_exec = {} @given("a parent plan with {n:d} subplans in parallel mode with last_wins merge") def step_parent_par_last_wins(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=n, merge_strategy=SubplanMergeStrategy.LAST_WINS, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("both subplans modify the same file differently") def step_same_file_different(context: Context) -> None: context.base_files_for_exec = {} @then("the execution result should include a merge result") def step_has_merge_result(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert result.merge_result is not None, "Expected merge result" @then("the merge result should have no conflicts") def step_no_conflicts(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert result.merge_result is not None assert len(result.merge_result.conflict_files) == 0 @then("the merge result should succeed") def step_merge_result_succeeds(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert result.merge_result is not None assert result.merge_result.success # --- Validation steps --- @when("a SubplanExecutionService is created with None config") def step_none_config(context: Context) -> None: try: SubplanExecutionService( config=None, # type: ignore[arg-type] executor_fn=_success_executor, ) context.validation_error = None except ValueError as exc: context.validation_error = exc @when("a SubplanExecutionService is created with None executor") def step_none_executor(context: Context) -> None: try: SubplanExecutionService( config=SubplanConfig(), executor_fn=None, # type: ignore[arg-type] ) context.validation_error = None except ValueError as exc: context.validation_error = exc @when("a SubplanMergeService is created with None strategy") def step_none_strategy(context: Context) -> None: try: SubplanMergeService(strategy=None) # type: ignore[arg-type] context.validation_error = None except ValueError as exc: context.validation_error = exc @then("a config validation error should be raised") def step_config_error(context: Context) -> None: assert context.validation_error is not None assert "config" in str(context.validation_error) @then("an executor validation error should be raised") def step_executor_error(context: Context) -> None: assert context.validation_error is not None assert "executor_fn" in str(context.validation_error) @then("a strategy validation error should be raised") def step_strategy_error(context: Context) -> None: assert context.validation_error is not None assert "strategy" in str(context.validation_error) @given("a valid SubplanExecutionService") def step_valid_service(context: Context) -> None: context.exec_service = SubplanExecutionService( config=SubplanConfig(), executor_fn=_success_executor, ) @when("execute_all is called with empty subplan statuses") def step_empty_statuses(context: Context) -> None: try: context.exec_service.execute_all( subplan_statuses=[], base_files={}, ) context.validation_error = None except ValueError as exc: context.validation_error = exc @then("an empty statuses error should be raised") def step_empty_statuses_error(context: Context) -> None: assert context.validation_error is not None assert "must not be empty" in str(context.validation_error) # --- Property accessor steps --- @then("the service config property should return the configured config") def step_config_property(context: Context) -> None: service: SubplanExecutionService = context.exec_service assert service.config is not None assert service.config.execution_mode == ExecutionMode.SEQUENTIAL @then("the service merge_service property should return a merge service") def step_merge_service_property(context: Context) -> None: service: SubplanExecutionService = context.exec_service assert service.merge_service is not None assert isinstance(service.merge_service, SubplanMergeService) @given("a SubplanMergeService with git_three_way strategy") def step_merge_service_instance(context: Context) -> None: context.merge_svc = SubplanMergeService(SubplanMergeStrategy.GIT_THREE_WAY) @then("the merge strategy property should return git_three_way") def step_strategy_property(context: Context) -> None: assert context.merge_svc.strategy == SubplanMergeStrategy.GIT_THREE_WAY # --- Retry edge-case steps --- @given('the subplan will fail with "{error}"') def step_subplan_always_fail(context: Context, error: str) -> None: context.fail_map[_S1] = error @given("a parent plan with {n:d} subplan in sequential mode with max_retries {mr:d}") def step_parent_sequential_max_retries(context: Context, n: int, mr: int) -> None: ids = [_S1, _S2, _S3, _S4, _S5][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, retry_failed=True, max_retries=mr, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given('the subplan will always fail with "{error}"') def step_subplan_always_fail_retriable(context: Context, error: str) -> None: context.fail_map[_S1] = error context.retry_map[_S1] = 999 # always fail (never enough retries) @given('the executor will raise "{error_type}" once then succeed') def step_executor_raises_once(context: Context, error_type: str) -> None: context.raise_map = {_S1: error_type} context.raise_counts = {_S1: 1} # raise once then succeed @given('the executor will raise "{error_type}" permanently') def step_executor_raises_permanent(context: Context, error_type: str) -> None: context.raise_map = {_S1: error_type} context.raise_counts = {_S1: 999} # always raise @then("the subplan should be errored") def step_single_errored(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert result.statuses[0].status == ProcessingState.ERRORED @then("the subplan attempt number should be {n:d}") def step_attempt_number(context: Context, n: int) -> None: result: SubplanExecutionResult = context.exec_result status = result.statuses[0] assert status.attempt_number == n, ( f"Expected attempt_number={n}, got {status.attempt_number}" ) # --- Timeout enforcement steps --- @given( "a parent plan with {n:d} subplan in sequential mode with a {t:d} second timeout" ) def step_parent_seq_timeout(context: Context, n: int, t: int) -> None: ids = [_S1, _S2, _S3, _S4, _S5][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, timeout_per_subplan_seconds=t, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("the subplan executor will block for {s:d} seconds") def step_slow_executor_all(context: Context, s: int) -> None: context.delay_map = { _S1: float(s), _S2: float(s), _S3: float(s), _S4: float(s), _S5: float(s), } @given("a parent plan with {n:d} subplans in parallel mode with a {t:d} second timeout") def step_parent_par_timeout(context: Context, n: int, t: int) -> None: ids = [_S1, _S2, _S3, _S4, _S5][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=n, timeout_per_subplan_seconds=t, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("the first subplan executor will block for {s:d} seconds") def step_first_slow(context: Context, s: int) -> None: context.delay_map = {_S1: float(s)} @then("the subplan error should mention timeout") def step_error_mentions_timeout(context: Context) -> None: result: SubplanExecutionResult = context.exec_result status = result.statuses[0] assert status.status == ProcessingState.ERRORED assert "timeout" in (status.error or "").lower(), ( f"Expected timeout in error message, got: {status.error}" ) @then("at least one subplan error should mention timeout") def step_at_least_one_timeout(context: Context) -> None: result: SubplanExecutionResult = context.exec_result errored = [s for s in result.statuses if s.status == ProcessingState.ERRORED] assert len(errored) > 0, "Expected at least one errored subplan" assert any("timeout" in (s.error or "").lower() for s in errored), ( f"No errored subplan mentions timeout: " f"{[(s.subplan_id, s.error) for s in errored]}" ) # --- Completion order steps --- @given("a parent plan with {n:d} subplans in parallel mode with staggered completion") def step_parent_parallel_staggered(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=n, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() # S1 takes longest, S3 shortest -> completion order: S3, S2, S1 context.delay_map = {_S1: 0.3, _S2: 0.2, _S3: 0.1} @then("the execution result statuses should be in completion order") def step_completion_order(context: Context) -> None: result: SubplanExecutionResult = context.exec_result completed_ids = [ s.subplan_id for s in result.statuses if s.status == ProcessingState.COMPLETE ] # With staggered delays: S3 (0.1s) first, S2 (0.2s), S1 (0.3s) last assert completed_ids[0] == _S3, f"Expected {_S3} first, got {completed_ids[0]}" assert completed_ids[-1] == _S1, f"Expected {_S1} last, got {completed_ids[-1]}" # --- Cancelled status steps --- @given( "a parent plan with {n:d} subplans in parallel mode" " with max_parallel {mp:d} and fail_fast" ) def step_parent_par_mp_failfast(context: Context, n: int, mp: int) -> None: ids = [_S1, _S2, _S3, _S4, _S5][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=mp, fail_fast=True, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @then("the remaining subplans should have CANCELLED status") def step_remaining_cancelled(context: Context) -> None: result: SubplanExecutionResult = context.exec_result for status in result.statuses: if status.subplan_id == _S1: continue # Skip the errored first subplan assert status.status == ProcessingState.CANCELLED, ( f"Expected CANCELLED for {status.subplan_id}, got {status.status}" ) # --- Dependency-ordered concurrent execution steps --- @given( "a parent plan with {n:d} subplans in dependency_ordered mode" " with concurrency tracking" ) def step_parent_dep_concurrent(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.DEPENDENCY_ORDERED, max_parallel=n, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() context.dependency_graph = {} context.concurrency_counter = {"current": 0, "max": 0} context.concurrency_lock = threading.Lock() # Give A and B enough time to overlap — use generous delays so that # concurrency is detected even under heavy CPU contention from the # parallel test runner. context.delay_map = {_S1: 0.5, _S2: 0.5, _S3: 0.05} @given("subplans A and B are independent while C depends on both") def step_ab_independent_c_depends(context: Context) -> None: context.dependency_graph = { _S1: [], _S2: [], _S3: [_S1, _S2], } @then("the peak concurrent execution count should be at least {n:d}") def step_peak_concurrency(context: Context, n: int) -> None: peak = context.concurrency_counter["max"] assert peak >= n, f"Expected peak concurrency >= {n}, got {peak}" @then("subplan C should have started after A and B completed") def step_c_after_ab(context: Context) -> None: order = context.execution_order c_first = next(i for i, sid in enumerate(order) if sid == _S3) a_first = next(i for i, sid in enumerate(order) if sid == _S1) b_first = next(i for i, sid in enumerate(order) if sid == _S2) assert a_first < c_first, f"A({a_first}) should execute before C({c_first})" assert b_first < c_first, f"B({b_first}) should execute before C({c_first})" # --- Dependency-ordered fail_fast steps --- @given("a parent plan with {n:d} subplans in dependency_ordered mode with fail_fast") def step_parent_dep_failfast(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.DEPENDENCY_ORDERED, fail_fast=True, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() context.dependency_graph = {} @then("the second and third subplans should have CANCELLED status") def step_second_third_cancelled(context: Context) -> None: result: SubplanExecutionResult = context.exec_result non_first = [s for s in result.statuses if s.subplan_id != _S1] for status in non_first: assert status.status == ProcessingState.CANCELLED, ( f"Expected CANCELLED for {status.subplan_id}, got {status.status}" ) # --- Dependency-ordered timeout steps --- @given( "a parent plan with {n:d} subplans in dependency_ordered mode" " with a {t:d} second timeout" ) def step_parent_dep_timeout(context: Context, n: int, t: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.DEPENDENCY_ORDERED, timeout_per_subplan_seconds=t, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() context.dependency_graph = {} @given("subplan B depends on subplan A") def step_b_depends_a(context: Context) -> None: context.dependency_graph = { _S1: [], _S2: [_S1], } @given( "a parent plan with {n:d} subplans in dependency_ordered mode" " with concurrency and a {t:d} second timeout" ) def step_parent_dep_concurrent_timeout(context: Context, n: int, t: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.DEPENDENCY_ORDERED, max_parallel=n, timeout_per_subplan_seconds=t, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() context.dependency_graph = {} # --- Parallel executor exception steps --- @given('the executor will raise "{error_type}" for the first subplan in parallel') def step_executor_raises_parallel(context: Context, error_type: str) -> None: context.raise_map = {_S1: error_type} context.raise_counts = {_S1: 999} # always raise # --- Integration: merge conflict during execution --- @given( "a parent plan with {n:d} subplans in sequential mode with fail_on_conflict merge" ) def step_parent_seq_fail_on_conflict(context: Context, n: int) -> None: ids = [_S1, _S2, _S3][:n] context.subplan_statuses = [_make_status(sid) for sid in ids] context.subplan_cfg = SubplanConfig( execution_mode=ExecutionMode.SEQUENTIAL, merge_strategy=SubplanMergeStrategy.FAIL_ON_CONFLICT, ) context.fail_map = {} context.retry_map = {} context.execution_order = [] context.order_lock = threading.Lock() @given("both subplans produce conflicting file changes") def step_conflicting_exec_outputs(context: Context) -> None: context.base_files_for_exec = {"src/main.py": "base content\n"} context.override_files = { _S1: {"src/main.py": "alpha conflict\n"}, _S2: {"src/main.py": "beta conflict\n"}, } @then("the execution result should report not all succeeded") def step_not_all_succeeded(context: Context) -> None: result: SubplanExecutionResult = context.exec_result assert not result.all_succeeded, "Expected not all_succeeded but got True"