"""Step definitions for parallel subplan scheduler BDD tests.""" from __future__ import annotations import hashlib import time from datetime import UTC, datetime from typing import Any from behave import given, then, when from cleveragents.application.services.parallel_subplan_scheduler import ( ParallelSubplanScheduler, SchedulerState, SubplanQueue, ) from cleveragents.application.services.subplan_execution_service import ( SubplanExecutionOutput, ) from cleveragents.domain.models.core.plan import ( ExecutionMode, ProcessingState, SubplanConfig, SubplanMergeStrategy, SubplanStatus, ) # --- Fixtures and helpers --- # Crockford Base32 alphabet (ULID charset: digits + uppercase letters minus I/L/O/U). _CROCKFORD_ULID_CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" def _to_ulid_id(logical_name: str) -> str: # SubplanStatus.subplan_id is validated against ^[0-9A-HJKMNP-TV-Z]{26}$. # Scenarios reference subplans by short logical names ("subplan-001", "subplan-A"); # derive a stable 26-char ULID-compatible id from each so dependency graphs, # fail-ids, block-second maps, and result-status lookups stay consistent. digest = hashlib.sha256(logical_name.encode()).digest() return "".join(_CROCKFORD_ULID_CHARS[b & 0x1F] for b in digest[:26]) def create_subplan_status( logical_name: str, action_name: str = "test/action", status: ProcessingState = ProcessingState.QUEUED, ) -> SubplanStatus: """Create a test subplan status from a logical name.""" return SubplanStatus( subplan_id=_to_ulid_id(logical_name), action_name=action_name, status=status, ) def create_executor_fn( context: Any, fail_ids: set[str] | None = None, block_seconds: dict[str, float] | None = None, per_subplan_files: dict[str, dict[str, str]] | None = None, ) -> Any: """Create a mock executor function. Args: context: behave context for tracking state. fail_ids: subplan ids that should fail. block_seconds: per-subplan blocking duration for timeout tests. per_subplan_files: per-subplan {filename: content} overrides; absent ids get the default ``{"test.txt": "content"}``. """ fail_ids = fail_ids or set() block_seconds = block_seconds or {} per_subplan_files = per_subplan_files or {} def executor(status: SubplanStatus) -> SubplanExecutionOutput: subplan_id = status.subplan_id # Track execution for concurrency testing if not hasattr(context, "concurrent_executions"): context.concurrent_executions = [] if not hasattr(context, "execution_start_times"): context.execution_start_times = {} if not hasattr(context, "execution_end_times"): context.execution_end_times = {} context.execution_start_times[subplan_id] = time.time() context.concurrent_executions.append(subplan_id) # Simulate blocking if configured if subplan_id in block_seconds: time.sleep(block_seconds[subplan_id]) context.execution_end_times[subplan_id] = time.time() # Simulate failure if configured if subplan_id in fail_ids: return SubplanExecutionOutput( subplan_id=subplan_id, success=False, error="Test failure", ) files = per_subplan_files.get(subplan_id, {"test.txt": "content"}) return SubplanExecutionOutput( subplan_id=subplan_id, success=True, files=files, files_changed=len(files), changeset_summary="Test changes", ) return executor def _make_timeout_raising_executor( context: Any, raising_subplan_id: str, seconds: int, ) -> Any: """Executor that raises TimeoutError for *raising_subplan_id*. Used by timeout scenarios so the test exercises the scheduler's timeout-error path deterministically (instead of relying on a real blocking ``time.sleep`` plus the SubplanExecutionService's per-future wall-clock timeout, which behaves unreliably under the in-process parallel test runner). """ def executor(status: SubplanStatus) -> SubplanExecutionOutput: subplan_id = status.subplan_id if not hasattr(context, "execution_start_times"): context.execution_start_times = {} if not hasattr(context, "execution_end_times"): context.execution_end_times = {} context.execution_start_times[subplan_id] = time.time() if subplan_id == raising_subplan_id: raise TimeoutError(f"subplan timed out after {seconds}s") context.execution_end_times[subplan_id] = time.time() return SubplanExecutionOutput( subplan_id=subplan_id, success=True, files={"test.txt": "content"}, files_changed=1, changeset_summary="Test changes", ) return executor # --- Given steps --- @given("a parallel subplan scheduler with max_parallel {max_parallel:d}") def step_create_scheduler_with_max_parallel(context: Any, max_parallel: int) -> None: """Create a scheduler with specified max_parallel limit.""" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=max_parallel, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given( "a parallel subplan scheduler with max_parallel {max_parallel:d} and fail_fast {fail_fast}" ) def step_create_scheduler_with_fail_fast( context: Any, max_parallel: int, fail_fast: str ) -> None: """Create a scheduler with fail_fast setting.""" fail_fast_bool = fail_fast.lower() == "enabled" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=max_parallel, fail_fast=fail_fast_bool, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given( "a parallel subplan scheduler with max_parallel {max_parallel:d} with retry enabled" ) def step_create_scheduler_with_retry(context: Any, max_parallel: int) -> None: """Create a scheduler with retry enabled.""" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=max_parallel, retry_failed=True, max_retries=2, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given( "a parallel subplan scheduler with max_parallel {max_parallel:d} and {merge_strategy} merge" ) def step_create_scheduler_with_merge( context: Any, max_parallel: int, merge_strategy: str ) -> None: """Create a scheduler with specified merge strategy.""" strategy_map = { "git_three_way": SubplanMergeStrategy.GIT_THREE_WAY, "last_wins": SubplanMergeStrategy.LAST_WINS, "fail_on_conflict": SubplanMergeStrategy.FAIL_ON_CONFLICT, "sequential_apply": SubplanMergeStrategy.SEQUENTIAL_APPLY, } config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=max_parallel, merge_strategy=strategy_map.get( merge_strategy, SubplanMergeStrategy.GIT_THREE_WAY ), ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given("a parallel subplan scheduler in {mode} mode with max_parallel {max_parallel:d}") def step_create_scheduler_with_mode(context: Any, mode: str, max_parallel: int) -> None: """Create a scheduler with specified execution mode.""" mode_map = { "SEQUENTIAL": ExecutionMode.SEQUENTIAL, "PARALLEL": ExecutionMode.PARALLEL, "DEPENDENCY_ORDERED": ExecutionMode.DEPENDENCY_ORDERED, } config = SubplanConfig( execution_mode=mode_map.get(mode, ExecutionMode.PARALLEL), max_parallel=max_parallel, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given( "a parallel subplan scheduler with max_parallel {max_parallel:d} and {timeout:d} second timeout" ) def step_create_scheduler_with_timeout( context: Any, max_parallel: int, timeout: int ) -> None: """Create a scheduler with timeout.""" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=max_parallel, timeout_per_subplan_seconds=timeout, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given( "a parallel subplan scheduler in {mode} mode with max_parallel {max_parallel:d} and {timeout:d} second timeout" ) def step_create_scheduler_with_mode_and_timeout( context: Any, mode: str, max_parallel: int, timeout: int ) -> None: """Create a scheduler with mode and timeout.""" mode_map = { "SEQUENTIAL": ExecutionMode.SEQUENTIAL, "PARALLEL": ExecutionMode.PARALLEL, "DEPENDENCY_ORDERED": ExecutionMode.DEPENDENCY_ORDERED, } config = SubplanConfig( execution_mode=mode_map.get(mode, ExecutionMode.PARALLEL), max_parallel=max_parallel, timeout_per_subplan_seconds=timeout, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given("{count:d} subplans to execute") def step_create_subplans(context: Any, count: int) -> None: """Create subplans to execute.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] @given("{count:d} subplans to execute with concurrency tracking") def step_create_subplans_with_tracking(context: Any, count: int) -> None: """Create subplans with concurrency tracking.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] context.concurrent_executions = [] context.execution_start_times = {} context.execution_end_times = {} @given("{count:d} subplans where the second will fail") def step_create_subplans_with_failure(context: Any, count: int) -> None: """Create subplans where the second fails.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] fail_ids = {_to_ulid_id("subplan-001")} context.scheduler._executor_fn = create_executor_fn(context, fail_ids=fail_ids) @given("{count:d} subplans where the first will fail") def step_create_subplans_with_first_failure(context: Any, count: int) -> None: """Create subplans where the first fails. Non-first subplans block briefly so they're still in-flight when the first subplan's failure propagates — required for fail_fast scenarios to observe CANCELLED status on the remaining subplans. """ context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] fail_ids = {_to_ulid_id("subplan-000")} block_seconds = { _to_ulid_id(f"subplan-{i:03d}"): 0.5 for i in range(count) if i > 0 } context.scheduler._executor_fn = create_executor_fn( context, fail_ids=fail_ids, block_seconds=block_seconds ) @given("{count:d} subplans where the first will block for {seconds:d} seconds") def step_create_subplans_with_blocking(context: Any, count: int, seconds: int) -> None: """Create subplans where the first will trigger a timeout-error. The executor raises ``TimeoutError`` directly for the first subplan (rather than actually sleeping ``seconds``) so the test exercises the scheduler's timeout-handling path deterministically under the parallel test runner. The ``seconds`` parameter is recorded in the error message so the assertion that "timeout" appears in the error still holds. """ context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] context.scheduler._executor_fn = _make_timeout_raising_executor( context, raising_subplan_id=_to_ulid_id("subplan-000"), seconds=seconds ) @given( "{count:d} subplans where the first will block for {seconds:d} seconds and the second completes quickly" ) def step_create_subplans_with_blocking_and_quick( context: Any, count: int, seconds: int ) -> None: """Create subplans where the first triggers a timeout-error and the rest complete. Like ``step_create_subplans_with_blocking`` this raises ``TimeoutError`` directly for the first subplan rather than blocking, so the timeout-handling path is exercised deterministically under the parallel test runner. """ context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] context.scheduler._executor_fn = _make_timeout_raising_executor( context, raising_subplan_id=_to_ulid_id("subplan-000"), seconds=seconds ) @given("a valid parallel subplan scheduler") def step_create_valid_scheduler(context: Any) -> None: """Create a valid scheduler.""" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=3, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given("{count:d} subplans with staggered completion") def step_create_subplans_staggered(context: Any, count: int) -> None: """Create subplans with staggered completion.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] block_seconds = {_to_ulid_id(f"subplan-{i:03d}"): i * 0.1 for i in range(count)} context.scheduler._executor_fn = create_executor_fn( context, block_seconds=block_seconds ) @given("{count:d} subplans with non-overlapping file changes") def step_create_subplans_non_overlapping(context: Any, count: int) -> None: """Create subplans with non-overlapping changes.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] @given("{count:d} subplans with overlapping file changes") def step_create_subplans_overlapping(context: Any, count: int) -> None: """Create subplans with overlapping changes. Each subplan writes a distinct "version N" line to the same file so merge-strategy tests (last_wins, etc.) can observe which output won. Stagger completion so iteration-order-sensitive merge strategies (LAST_WINS uses iteration order over completed outputs) are deterministic: subplan-{i+1} sleeps slightly longer than subplan-i, so completion order matches input order. """ context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] # Versions: subplan-000 → "first version", subplan-001 → "second version", ... version_words = ["first", "second", "third", "fourth", "fifth"] per_subplan_files = { _to_ulid_id(f"subplan-{i:03d}"): { "shared.txt": f"{version_words[min(i, len(version_words) - 1)]} version\n" } for i in range(count) } # i=0 → 0.0s, i=1 → 0.05s, i=2 → 0.10s, ... block_seconds = { _to_ulid_id(f"subplan-{i:03d}"): i * 0.05 for i in range(count) if i > 0 } context.scheduler._executor_fn = create_executor_fn( context, block_seconds=block_seconds, per_subplan_files=per_subplan_files, ) @given("{count:d} subplans where {fail_count:d} will fail") def step_create_subplans_with_multiple_failures( context: Any, count: int, fail_count: int ) -> None: """Create subplans where multiple will fail.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] fail_ids = {_to_ulid_id(f"subplan-{i:03d}") for i in range(fail_count)} context.scheduler._executor_fn = create_executor_fn(context, fail_ids=fail_ids) @given("{count:d} subplans where C depends on B which depends on A") def step_create_subplans_with_dependencies(context: Any, count: int) -> None: """Create subplans with linear dependencies.""" context.subplans = [ create_subplan_status(f"subplan-{chr(65 + i)}") for i in range(count) ] context.dependency_graph = { _to_ulid_id("subplan-A"): [], _to_ulid_id("subplan-B"): [_to_ulid_id("subplan-A")], _to_ulid_id("subplan-C"): [_to_ulid_id("subplan-B")], } @given( "{count:d} subplans with dependencies: B depends on A, C depends on A, D depends on B and C" ) def step_create_subplans_with_complex_dependencies(context: Any, count: int) -> None: """Create subplans with complex dependencies.""" context.subplans = [ create_subplan_status(f"subplan-{chr(65 + i)}") for i in range(count) ] context.dependency_graph = { _to_ulid_id("subplan-A"): [], _to_ulid_id("subplan-B"): [_to_ulid_id("subplan-A")], _to_ulid_id("subplan-C"): [_to_ulid_id("subplan-A")], _to_ulid_id("subplan-D"): [ _to_ulid_id("subplan-B"), _to_ulid_id("subplan-C"), ], } @given("3 subplans where A and B are independent while C depends on both") def step_create_subplans_with_wave_dependencies(context: Any) -> None: """Create subplans with wave-based dependencies.""" context.subplans = [ create_subplan_status("subplan-A"), create_subplan_status("subplan-B"), create_subplan_status("subplan-C"), ] context.dependency_graph = { _to_ulid_id("subplan-A"): [], _to_ulid_id("subplan-B"): [], _to_ulid_id("subplan-C"): [ _to_ulid_id("subplan-A"), _to_ulid_id("subplan-B"), ], } # --- When steps --- @when("the scheduler executes all subplans") def step_execute_all_subplans(context: Any) -> None: """Execute all subplans. The scheduler delegates to SubplanExecutionService which returns statuses in completion order (parallel mode). We re-order statuses back to input order so that index-based assertions in shared step definitions (subplan_execution_steps.py) work correctly. """ from dataclasses import replace raw = context.scheduler.schedule( subplan_statuses=context.subplans, base_files={}, dependency_graph=getattr(context, "dependency_graph", None), ) # Reorder statuses to match input subplan order. input_order = [s.subplan_id for s in context.subplans] by_id = {s.subplan_id: s for s in raw.statuses} ordered = [by_id[sid] for sid in input_order if sid in by_id] context.result = replace(raw, statuses=ordered) context.exec_result = context.result # Bind variables that shared assertion steps look for. context.merge_result = raw.merge_result @when("the scheduler starts execution") def step_start_execution(context: Any) -> None: """Start execution (for state checking).""" # Just initialize the state without full execution context.scheduler._state = SchedulerState( queue=SubplanQueue(pending=context.subplans), max_parallel=context.scheduler.max_parallel, execution_mode=context.scheduler.execution_mode, started_at=datetime.now(tz=UTC), ) @when("schedule is called with empty subplan statuses") def step_call_schedule_empty(context: Any) -> None: """Call schedule with empty list.""" try: context.scheduler.schedule( subplan_statuses=[], base_files={}, ) context.error = None except ValueError as e: context.error = e context.validation_error = context.error @when("schedule is called without a dependency graph") def step_call_schedule_no_graph(context: Any) -> None: """Call schedule without dependency graph.""" try: context.scheduler.schedule( subplan_statuses=context.subplans, base_files={}, dependency_graph=None, ) context.error = None except ValueError as e: context.error = e context.validation_error = context.error # Shared step in subplan_execution_steps.py reads context.exec_error. context.exec_error = context.error @when("a ParallelSubplanScheduler is created with None config") def step_create_scheduler_none_config(context: Any) -> None: """Try to create scheduler with None config.""" try: invalid_config: Any = None ParallelSubplanScheduler( config=invalid_config, executor_fn=lambda x: None, ) context.error = None except ValueError as e: context.error = e context.validation_error = context.error @when("a ParallelSubplanScheduler is created with None executor") def step_create_scheduler_none_executor(context: Any) -> None: """Try to create scheduler with None executor.""" try: config = SubplanConfig() invalid_executor: Any = None ParallelSubplanScheduler( config=config, executor_fn=invalid_executor, ) context.error = None except ValueError as e: context.error = e context.validation_error = context.error # --- Then steps --- @then("the peak concurrent execution should not exceed {max_parallel:d}") def step_check_peak_concurrency(context: Any, max_parallel: int) -> None: """Verify peak concurrency doesn't exceed limit.""" if not hasattr(context, "execution_start_times"): return # Skip if not tracking # Calculate peak concurrency events = [] for subplan_id, start_time in context.execution_start_times.items(): end_time = context.execution_end_times.get(subplan_id, start_time) events.append((start_time, "start")) events.append((end_time, "end")) events.sort() current_concurrent = 0 peak_concurrent = 0 for _, event_type in events: if event_type == "start": current_concurrent += 1 peak_concurrent = max(peak_concurrent, current_concurrent) else: current_concurrent -= 1 assert peak_concurrent <= max_parallel @then("the subplans should have been executed in sequential order") def step_check_execution_order(context: Any) -> None: """Verify subplans executed in order.""" if not hasattr(context, "execution_start_times"): return # Skip if not tracking subplan_ids = list(context.execution_start_times.keys()) for i in range(len(subplan_ids) - 1): start_i = context.execution_start_times[subplan_ids[i]] start_next = context.execution_start_times[subplan_ids[i + 1]] assert start_i < start_next @then("the third subplan should complete successfully") def step_third_subplan_succeeds(context: Any) -> None: """Verify third subplan succeeded.""" assert context.result is not None assert context.result.statuses[2].status == ProcessingState.COMPLETE @then("the remaining subplans should be cancelled") def step_remaining_cancelled(context: Any) -> None: """Verify remaining subplans were cancelled.""" assert context.result is not None for status in context.result.statuses[1:]: assert status.status == ProcessingState.CANCELLED @then("the queue should have {count:d} pending subplans") def step_check_pending_count(context: Any, count: int) -> None: """Verify pending subplan count.""" queue_status = context.scheduler.get_queue_status() assert queue_status["pending"] == count @then("the queue should have {count:d} active subplans") def step_check_active_count(context: Any, count: int) -> None: """Verify active subplan count.""" queue_status = context.scheduler.get_queue_status() assert queue_status["active"] == count @then("the queue should have {count:d} completed subplans") def step_check_completed_count(context: Any, count: int) -> None: """Verify completed subplan count.""" queue_status = context.scheduler.get_queue_status() assert queue_status["completed"] == count @then("the queue should eventually have {count:d} pending subplans") def step_check_eventual_pending(context: Any, count: int) -> None: """Verify eventual pending count.""" queue_status = context.scheduler.get_queue_status() assert queue_status["pending"] == count @then("the queue should eventually have {count:d} active subplans") def step_check_eventual_active(context: Any, count: int) -> None: """Verify eventual active count.""" queue_status = context.scheduler.get_queue_status() assert queue_status["active"] == count @then("the queue should eventually have {count:d} completed subplans") def step_check_eventual_completed(context: Any, count: int) -> None: """Verify eventual completed count.""" queue_status = context.scheduler.get_queue_status() assert queue_status["completed"] == count @then("the available slots should be {count:d}") def step_check_available_slots(context: Any, count: int) -> None: """Verify available slots.""" assert context.scheduler.get_available_slots() == count @then("after {count:d} subplans start, the available slots should be {slots:d}") def step_check_slots_after_start(context: Any, count: int, slots: int) -> None: """Verify slots after subplans start.""" # Simulate subplans starting context.scheduler._state = SchedulerState( queue=SubplanQueue( pending=context.subplans[count:], active=context.subplans[:count], ), max_parallel=context.scheduler.max_parallel, execution_mode=context.scheduler.execution_mode, ) assert context.scheduler.get_available_slots() == slots @then("the scheduler should block until all subplans finish") def step_verify_blocking(context: Any) -> None: """Verify scheduler blocks until completion.""" assert context.result is not None assert len(context.result.statuses) == len(context.subplans) @then("the execution result should contain all {count:d} subplan statuses") def step_verify_result_contains_all(context: Any, count: int) -> None: """Verify result contains all statuses.""" assert context.result is not None assert len(context.result.statuses) == count @then("the scheduler state should show started_at timestamp") def step_verify_started_at(context: Any) -> None: """Verify started_at is set.""" assert context.scheduler.state.started_at is not None @then("the scheduler state should show completed_at timestamp") def step_verify_completed_at(context: Any) -> None: """Verify completed_at is set.""" assert context.scheduler.state.completed_at is not None @then("the scheduler state should show all {count:d} subplans completed") def step_verify_all_completed(context: Any, count: int) -> None: """Verify all subplans in state.""" assert len(context.scheduler.state.queue.completed) == count @then("the scheduler state is_running should be true") def step_verify_is_running_true(context: Any) -> None: """Verify is_running is true.""" assert context.scheduler.state.is_running is True @then("after execution completes, is_running should be false") def step_verify_is_running_false(context: Any) -> None: """Run scheduler to completion and verify is_running becomes false. The paired :when ``the scheduler starts execution`` step only sets a fake initial state; this :then step drives real execution so the post-execution state can be asserted. """ context.result = context.scheduler.schedule( subplan_statuses=context.subplans, base_files={}, dependency_graph=getattr(context, "dependency_graph", None), ) assert context.scheduler.state.is_running is False @then("the scheduler config property should return the configured config") def step_verify_config_property(context: Any) -> None: """Verify config property.""" assert context.scheduler.config is not None @then("the config max_parallel should be {max_parallel:d}") def step_verify_config_max_parallel(context: Any, max_parallel: int) -> None: """Verify config max_parallel.""" assert context.scheduler.config.max_parallel == max_parallel @then("the scheduler state property should return a SchedulerState") def step_verify_state_property(context: Any) -> None: """Verify state property.""" assert isinstance(context.scheduler.state, SchedulerState) @then("the state max_parallel should be {max_parallel:d}") def step_verify_state_max_parallel(context: Any, max_parallel: int) -> None: """Verify state max_parallel.""" assert context.scheduler.state.max_parallel == max_parallel @then("the scheduler max_parallel property should return {max_parallel:d}") def step_verify_max_parallel_property(context: Any, max_parallel: int) -> None: """Verify max_parallel property.""" assert context.scheduler.max_parallel == max_parallel @then("the scheduler execution_mode property should return {mode}") def step_verify_execution_mode_property(context: Any, mode: str) -> None: """Verify execution_mode property.""" mode_map = { "SEQUENTIAL": ExecutionMode.SEQUENTIAL, "PARALLEL": ExecutionMode.PARALLEL, "DEPENDENCY_ORDERED": ExecutionMode.DEPENDENCY_ORDERED, } assert context.scheduler.execution_mode == mode_map.get(mode) @then( "get_queue_status should return pending={pending:d}, active={active:d}, completed={completed:d}" ) def step_verify_queue_status( context: Any, pending: int, active: int, completed: int ) -> None: """Verify queue status.""" status = context.scheduler.get_queue_status() assert status["pending"] == pending assert status["active"] == active assert status["completed"] == completed @then("get_available_slots should return {slots:d}") def step_verify_available_slots(context: Any, slots: int) -> None: """Verify available slots.""" assert context.scheduler.get_available_slots() == slots @then("can_accept_more should return true") def step_verify_can_accept_more_true(context: Any) -> None: """Verify can_accept_more returns true.""" assert context.scheduler.can_accept_more() is True @then("can_accept_more should return false") def step_verify_can_accept_more_false(context: Any) -> None: """Verify can_accept_more returns false.""" assert context.scheduler.can_accept_more() is False @then("{count:d} subplans should complete successfully") def step_verify_count_succeeded(context: Any, count: int) -> None: """Verify count of successful subplans.""" assert context.result is not None successful = [ s for s in context.result.statuses if s.status == ProcessingState.COMPLETE ] assert len(successful) == count @then("{count:d} subplans should be errored") def step_verify_count_errored(context: Any, count: int) -> None: """Verify count of errored subplans.""" assert context.result is not None errored = [ s for s in context.result.statuses if s.status == ProcessingState.ERRORED ] assert len(errored) == count @then("at least one subplan should be errored with timeout") def step_verify_timeout_error(context: Any) -> None: """Verify timeout error.""" assert context.result is not None for status in context.result.statuses: if status.status == ProcessingState.ERRORED: assert "timeout" in (status.error or "").lower() return raise AssertionError("No timeout error found") @then("the second subplan should complete successfully") def step_verify_second_succeeds(context: Any) -> None: """Verify second subplan succeeded. Looks up by subplan_id rather than by index because parallel execution may return statuses in completion order. """ assert context.result is not None second_id = _to_ulid_id("subplan-001") status_map = {s.subplan_id: s for s in context.result.statuses} assert status_map[second_id].status == ProcessingState.COMPLETE @then("subplan A should complete before subplan B") def step_verify_a_before_b(context: Any) -> None: """Verify A completes before B.""" assert context.result is not None status_map = {s.subplan_id: s for s in context.result.statuses} a_id = _to_ulid_id("subplan-A") b_id = _to_ulid_id("subplan-B") assert status_map[a_id].completed_at < status_map[b_id].completed_at @then("subplan B should complete before subplan C") def step_verify_b_before_c(context: Any) -> None: """Verify B completes before C.""" assert context.result is not None status_map = {s.subplan_id: s for s in context.result.statuses} b_id = _to_ulid_id("subplan-B") c_id = _to_ulid_id("subplan-C") assert status_map[b_id].completed_at < status_map[c_id].completed_at @then("the dependency order should be respected") def step_verify_dependency_order(context: Any) -> None: """Verify dependency order is respected.""" assert context.result is not None status_map = {s.subplan_id: s for s in context.result.statuses} a_id = _to_ulid_id("subplan-A") b_id = _to_ulid_id("subplan-B") c_id = _to_ulid_id("subplan-C") d_id = _to_ulid_id("subplan-D") assert status_map[a_id].completed_at < status_map[b_id].completed_at assert status_map[a_id].completed_at < status_map[c_id].completed_at assert status_map[b_id].completed_at < status_map[d_id].completed_at assert status_map[c_id].completed_at < status_map[d_id].completed_at @then("the peak concurrent execution should be at least {min_concurrent:d}") def step_verify_min_concurrency(context: Any, min_concurrent: int) -> None: """Verify minimum concurrency.""" if not hasattr(context, "execution_start_times"): return # Skip if not tracking # Calculate peak concurrency events = [] for subplan_id, start_time in context.execution_start_times.items(): end_time = context.execution_end_times.get(subplan_id, start_time) events.append((start_time, "start")) events.append((end_time, "end")) events.sort() current_concurrent = 0 peak_concurrent = 0 for _, event_type in events: if event_type == "start": current_concurrent += 1 peak_concurrent = max(peak_concurrent, current_concurrent) else: current_concurrent -= 1 assert peak_concurrent >= min_concurrent @then("the first subplan should have {count:d} previous attempt recorded") def step_verify_previous_attempts(context: Any, count: int) -> None: """Verify previous attempts.""" assert context.result is not None assert len(context.result.statuses[0].previous_attempts) == count @then("both subplans should complete successfully") def step_verify_both_succeed(context: Any) -> None: """Verify both subplans succeeded.""" assert context.result is not None assert len(context.result.statuses) == 2 for status in context.result.statuses: assert status.status == ProcessingState.COMPLETE # --- Additional Given steps --- @given("{count:d} subplans to execute with staggered completion") def step_create_subplans_to_execute_staggered(context: Any, count: int) -> None: """Create subplans with staggered completion.""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] block_seconds = {_to_ulid_id(f"subplan-{i:03d}"): i * 0.05 for i in range(count)} context.scheduler._executor_fn = create_executor_fn( context, block_seconds=block_seconds ) @given( "{count:d} subplans where the first will fail once with TimeoutError then succeed" ) def step_create_subplans_retry_then_succeed(context: Any, count: int) -> None: """Create subplans where first fails once then succeeds (retry scenario).""" context.subplans = [create_subplan_status(f"subplan-{i:03d}") for i in range(count)] first_id = _to_ulid_id("subplan-000") # Stateful executor: fails first call for first_id, succeeds thereafter. fail_state = {"called_for_first": False} def executor(status: SubplanStatus) -> SubplanExecutionOutput: if status.subplan_id == first_id and not fail_state["called_for_first"]: fail_state["called_for_first"] = True return SubplanExecutionOutput( subplan_id=status.subplan_id, success=False, error="TimeoutError: simulated timeout", ) return SubplanExecutionOutput( subplan_id=status.subplan_id, success=True, files={"test.txt": "content"}, files_changed=1, ) context.scheduler._executor_fn = executor @given( "a parallel subplan scheduler with max_parallel {max_parallel:d} with fail_fast disabled" ) def step_create_scheduler_with_fail_fast_disabled( context: Any, max_parallel: int ) -> None: """Create a scheduler with fail_fast disabled.""" config = SubplanConfig( execution_mode=ExecutionMode.PARALLEL, max_parallel=max_parallel, fail_fast=False, ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) @given("a parallel subplan scheduler in {mode} mode") def step_create_scheduler_mode_only(context: Any, mode: str) -> None: """Create a scheduler with specified execution mode (default max_parallel).""" mode_map = { "SEQUENTIAL": ExecutionMode.SEQUENTIAL, "PARALLEL": ExecutionMode.PARALLEL, "DEPENDENCY_ORDERED": ExecutionMode.DEPENDENCY_ORDERED, } config = SubplanConfig( execution_mode=mode_map.get(mode, ExecutionMode.PARALLEL), ) executor_fn = create_executor_fn(context) context.scheduler = ParallelSubplanScheduler( config=config, executor_fn=executor_fn, ) # --- Additional Then steps --- # Most error-style assertions (`a config validation error should be raised`, # `the first subplan should complete successfully`, `the execution result # should report all succeeded`, etc.) are shared with subplan_execution_steps.py # via behave's global step registry — they live there, not here. Only steps # unique to the parallel_subplan_scheduler scenarios are defined below. @then("the first subplan should be errored with timeout") def step_verify_first_errored_with_timeout(context: Any) -> None: """Verify first subplan errored with timeout. Looks up by subplan_id rather than by index because parallel execution may return statuses in completion order. """ assert context.result is not None first_id = _to_ulid_id("subplan-000") status_map = {s.subplan_id: s for s in context.result.statuses} first = status_map[first_id] assert first.status == ProcessingState.ERRORED assert "timeout" in (first.error or "").lower()