Files
cleveragents-core/features/steps/subplan_execution_steps.py
brent.edwards 01b6eb1804
CI / build (push) Successful in 17s
CI / helm (push) Successful in 22s
CI / lint (push) Successful in 28s
CI / typecheck (push) Successful in 47s
CI / benchmark-regression (push) Has been skipped
CI / quality (push) Successful in 3m49s
CI / security (push) Successful in 4m11s
CI / unit_tests (push) Successful in 9m19s
CI / docker (push) Successful in 1m22s
CI / coverage (push) Successful in 12m35s
CI / e2e_tests (push) Successful in 16m17s
CI / integration_tests (push) Successful in 25m5s
CI / status-check (push) Successful in 2s
CI / benchmark-publish (push) Successful in 28m31s
feat(autonomy): parallel execution scales to 10+ concurrent subplans (#1201)
## Summary

Add M6 parallel-scaling coverage for 10+ concurrent subplans:

- **15-subplan parallel scenario** with explicit peak-concurrency bound checks (`max_parallel=10`) and thread-safe concurrency tracking via `_build_executor()`.
- **Deep hierarchical decomposition** coverage (4+ levels) with adjusted leaf condition that only stops early when hitting `max_depth` or when the workset is trivially small (`min_files_per_subplan`).
- **Non-progress guard** in `_build_hierarchy` to prevent pathological recursion when clustering cannot meaningfully split the file set.
- **Small-project regression test** (< 50 files) verifying decomposition depth does not increase unexpectedly with the relaxed leaf condition.
- **ASV benchmark** for 15-subplan parallel execution with `max_parallel=10` to track scaling behavior.

### Removed from this PR

The `_build_hierarchy` child-linkage correctness fix (returning `node_id` from recursive calls instead of using `nodes[-1].node_id`) has been **removed** per review feedback — it is a separate bug fix and will be submitted as an independent issue/PR per CONTRIBUTING.md §Atomic Commits.

## Approach

- **Concurrency tracking:** The `_build_executor()` closure in step definitions detects `context.concurrency_counter` / `context.concurrency_lock` and performs thread-safe peak tracking in a try/finally block.
- **Leaf condition:** Replaced the `max_files_per_subplan` / `max_tokens_per_subplan` leaf check with a `min_files_per_subplan` check to allow deeper decomposition for large projects. Added a non-progress guard so clustering that cannot split the file set terminates immediately rather than recursing to `max_depth`.
- **Deterministic IDs:** `_ids_for_count()` preserves legacy fixed IDs for the first 5 subplans and generates additional deterministic IDs for scale scenarios.

## Validation

### Passing
- `nox -s lint` — all checks passed
- `nox -s typecheck` — 0 errors, 0 warnings
- `nox -s unit_tests` — 12,988 scenarios passed, 0 failed
- `nox -s coverage_report` — 97% (passes `--fail-under=97`)

Closes #855

Reviewed-on: #1201
Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
2026-03-31 23:57:39 +00:00

1156 lines
39 KiB
Python

"""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"