9f6fb667c2
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 20s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 34s
CI / integration_tests (pull_request) Successful in 2m45s
CI / unit_tests (pull_request) Successful in 13m14s
CI / docker (pull_request) Successful in 38s
CI / benchmark-regression (pull_request) Successful in 24m49s
CI / coverage (pull_request) Successful in 49m5s
CI / lint (push) Successful in 14s
CI / quality (push) Successful in 15s
CI / build (push) Successful in 14s
CI / security (push) Successful in 28s
CI / typecheck (push) Successful in 30s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m43s
CI / unit_tests (push) Successful in 13m9s
CI / docker (push) Successful in 39s
CI / benchmark-publish (push) Successful in 14m49s
CI / coverage (push) Successful in 47m53s
Implement SubplanExecutionService and SubplanMergeService to enable parent plans to decompose work into coordinated child subplans with configurable execution modes (sequential, parallel, dependency-ordered) and merge strategies (git_three_way, sequential_apply, fail_on_conflict, last_wins). - SubplanExecutionService: schedules subplan execution with retry support via SubplanFailureHandler, max_parallel limits, fail_fast semantics, and topological DAG ordering for dependency mode - SubplanMergeService: wraps sandbox merge infrastructure to combine subplan sandbox outputs using the configured merge strategy - BDD tests: 21 scenarios covering all execution modes, merge strategies, validation, and integration flows - Robot tests: 11 integration test cases with helper module - ASV benchmarks: performance benchmarks for execution and merge - Documentation: reference guide in docs/reference/subplans.md ISSUES CLOSED: #184
344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""Helper script for subplan execution Robot Framework smoke tests.
|
|
|
|
Exercises SubplanExecutionService, SubplanMergeService, and integrated
|
|
execution+merge workflows without requiring the full service layer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import threading
|
|
|
|
from cleveragents.application.services.subplan_execution_service import (
|
|
SubplanExecutionOutput,
|
|
SubplanExecutionService,
|
|
)
|
|
from cleveragents.application.services.subplan_merge_service import (
|
|
MergeConflictError,
|
|
SubplanMergeService,
|
|
)
|
|
from cleveragents.domain.models.core.plan import (
|
|
ExecutionMode,
|
|
ProcessingState,
|
|
SubplanConfig,
|
|
SubplanMergeStrategy,
|
|
SubplanStatus,
|
|
)
|
|
|
|
_S1 = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
|
|
_S2 = "01HGZ6FE0AQDYTR4BXVQZ6EB00"
|
|
_S3 = "01HGZ6FE0AQDYTR4BXVQZ6EC00"
|
|
|
|
|
|
def _make_status(subplan_id: str) -> SubplanStatus:
|
|
return SubplanStatus(
|
|
subplan_id=subplan_id,
|
|
action_name="local/robot-sub-action",
|
|
)
|
|
|
|
|
|
def _success_executor(status: SubplanStatus) -> SubplanExecutionOutput:
|
|
return SubplanExecutionOutput(
|
|
subplan_id=status.subplan_id,
|
|
success=True,
|
|
files={f"src/{status.subplan_id[-4:]}.py": f"# {status.subplan_id}\n"},
|
|
files_changed=1,
|
|
changeset_summary="Robot test output",
|
|
)
|
|
|
|
|
|
def _sequential_all() -> None:
|
|
"""Verify sequential execution completes all subplans."""
|
|
statuses = [_make_status(_S1), _make_status(_S2), _make_status(_S3)]
|
|
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
|
|
service = SubplanExecutionService(config=config, executor_fn=_success_executor)
|
|
result = service.execute_all(subplan_statuses=statuses, base_files={})
|
|
assert result.all_succeeded, f"Not all succeeded: {result.failed_subplan_ids}"
|
|
completed = [s for s in result.statuses if s.status == ProcessingState.COMPLETE]
|
|
assert len(completed) == 3, f"Expected 3, got {len(completed)}"
|
|
print("sequential-all-ok")
|
|
|
|
|
|
def _sequential_failfast() -> None:
|
|
"""Verify sequential fail_fast stops after first failure."""
|
|
fail_set = {_S2}
|
|
|
|
def executor(status: SubplanStatus) -> SubplanExecutionOutput:
|
|
if status.subplan_id in fail_set:
|
|
return SubplanExecutionOutput(
|
|
subplan_id=status.subplan_id,
|
|
success=False,
|
|
error="ValidationError: bad input",
|
|
)
|
|
return _success_executor(status)
|
|
|
|
statuses = [_make_status(_S1), _make_status(_S2), _make_status(_S3)]
|
|
config = SubplanConfig(
|
|
execution_mode=ExecutionMode.SEQUENTIAL,
|
|
fail_fast=True,
|
|
)
|
|
service = SubplanExecutionService(config=config, executor_fn=executor)
|
|
result = service.execute_all(subplan_statuses=statuses, base_files={})
|
|
assert result.statuses[0].status == ProcessingState.COMPLETE
|
|
assert result.statuses[1].status == ProcessingState.ERRORED
|
|
assert result.statuses[2].status == ProcessingState.CANCELLED
|
|
print("sequential-failfast-ok")
|
|
|
|
|
|
def _parallel_all() -> None:
|
|
"""Verify parallel execution completes all subplans."""
|
|
statuses = [_make_status(_S1), _make_status(_S2), _make_status(_S3)]
|
|
config = SubplanConfig(execution_mode=ExecutionMode.PARALLEL, max_parallel=3)
|
|
service = SubplanExecutionService(config=config, executor_fn=_success_executor)
|
|
result = service.execute_all(subplan_statuses=statuses, base_files={})
|
|
assert result.all_succeeded
|
|
print("parallel-all-ok")
|
|
|
|
|
|
def _dep_ordered() -> None:
|
|
"""Verify dependency-ordered execution follows topological order."""
|
|
order: list[str] = []
|
|
lock = threading.Lock()
|
|
|
|
def executor(status: SubplanStatus) -> SubplanExecutionOutput:
|
|
with lock:
|
|
order.append(status.subplan_id)
|
|
return _success_executor(status)
|
|
|
|
statuses = [_make_status(_S1), _make_status(_S2), _make_status(_S3)]
|
|
config = SubplanConfig(execution_mode=ExecutionMode.DEPENDENCY_ORDERED)
|
|
dep_graph = {_S1: [], _S2: [_S1], _S3: [_S2]}
|
|
service = SubplanExecutionService(config=config, executor_fn=executor)
|
|
result = service.execute_all(
|
|
subplan_statuses=statuses,
|
|
base_files={},
|
|
dependency_graph=dep_graph,
|
|
)
|
|
assert result.all_succeeded
|
|
# Verify order: first occurrences should be A, B, C
|
|
seen: list[str] = []
|
|
for sid in order:
|
|
if sid not in seen:
|
|
seen.append(sid)
|
|
assert seen == [_S1, _S2, _S3], f"Expected [A,B,C], got {seen}"
|
|
print("dep-ordered-ok")
|
|
|
|
|
|
def _merge_git_clean() -> None:
|
|
"""Verify git three-way merge with non-overlapping changes."""
|
|
service = SubplanMergeService(SubplanMergeStrategy.GIT_THREE_WAY)
|
|
base = {"src/main.py": "line1\nline2\nline3\n"}
|
|
outputs = [
|
|
(_S1, {"src/main.py": "line1\nline2\nline3\nnew_a\n"}),
|
|
(_S2, {"src/main.py": "new_b\nline1\nline2\nline3\n"}),
|
|
]
|
|
result = service.merge(base, outputs)
|
|
assert result.success, f"Merge failed: {result.conflict_files}"
|
|
print("merge-git-clean-ok")
|
|
|
|
|
|
def _merge_sequential() -> None:
|
|
"""Verify sequential apply merge."""
|
|
service = SubplanMergeService(SubplanMergeStrategy.SEQUENTIAL_APPLY)
|
|
base = {"src/main.py": "original\n"}
|
|
outputs = [
|
|
(_S1, {"src/main.py": "first\n"}),
|
|
(_S2, {"src/main.py": "second\n"}),
|
|
]
|
|
result = service.merge(base, outputs)
|
|
assert result.success
|
|
assert "second" in result.merged_files[0].content
|
|
print("merge-sequential-ok")
|
|
|
|
|
|
def _merge_last_wins() -> None:
|
|
"""Verify last-wins merge takes final output."""
|
|
service = SubplanMergeService(SubplanMergeStrategy.LAST_WINS)
|
|
base = {"src/main.py": "original\n"}
|
|
outputs = [
|
|
(_S1, {"src/main.py": "first\n"}),
|
|
(_S2, {"src/main.py": "second\n"}),
|
|
]
|
|
result = service.merge(base, outputs)
|
|
assert result.success
|
|
assert result.merged_files[0].content == "second\n"
|
|
print("merge-last-wins-ok")
|
|
|
|
|
|
def _merge_fail_conflict() -> None:
|
|
"""Verify fail-on-conflict raises MergeConflictError."""
|
|
service = SubplanMergeService(SubplanMergeStrategy.FAIL_ON_CONFLICT)
|
|
base = {"src/main.py": "base line\n"}
|
|
outputs = [
|
|
(_S1, {"src/main.py": "alpha\n"}),
|
|
(_S2, {"src/main.py": "beta\n"}),
|
|
]
|
|
try:
|
|
service.merge(base, outputs)
|
|
raise AssertionError("Expected MergeConflictError")
|
|
except MergeConflictError:
|
|
pass
|
|
print("merge-fail-conflict-ok")
|
|
|
|
|
|
def _exec_merge_integration() -> None:
|
|
"""Verify end-to-end execution + merge integration."""
|
|
statuses = [_make_status(_S1), _make_status(_S2)]
|
|
config = SubplanConfig(
|
|
execution_mode=ExecutionMode.SEQUENTIAL,
|
|
merge_strategy=SubplanMergeStrategy.LAST_WINS,
|
|
)
|
|
service = SubplanExecutionService(config=config, executor_fn=_success_executor)
|
|
result = service.execute_all(subplan_statuses=statuses, base_files={})
|
|
assert result.all_succeeded
|
|
assert result.merge_result is not None
|
|
assert result.merge_result.success
|
|
print("exec-merge-integration-ok")
|
|
|
|
|
|
def _validation_guards() -> None:
|
|
"""Verify service validation guards."""
|
|
# None config
|
|
try:
|
|
SubplanExecutionService(
|
|
config=None, # type: ignore[arg-type]
|
|
executor_fn=_success_executor,
|
|
)
|
|
raise AssertionError("Expected ValueError for None config")
|
|
except ValueError:
|
|
pass
|
|
|
|
# None executor
|
|
try:
|
|
SubplanExecutionService(
|
|
config=SubplanConfig(),
|
|
executor_fn=None, # type: ignore[arg-type]
|
|
)
|
|
raise AssertionError("Expected ValueError for None executor")
|
|
except ValueError:
|
|
pass
|
|
|
|
# None strategy
|
|
try:
|
|
SubplanMergeService(strategy=None) # type: ignore[arg-type]
|
|
raise AssertionError("Expected ValueError for None strategy")
|
|
except ValueError:
|
|
pass
|
|
|
|
# Empty statuses
|
|
svc = SubplanExecutionService(config=SubplanConfig(), executor_fn=_success_executor)
|
|
try:
|
|
svc.execute_all(subplan_statuses=[], base_files={})
|
|
raise AssertionError("Expected ValueError for empty statuses")
|
|
except ValueError:
|
|
pass
|
|
|
|
print("validation-guards-ok")
|
|
|
|
|
|
def _exec_summary() -> None:
|
|
"""Verify execution result summary report."""
|
|
statuses = [_make_status(_S1), _make_status(_S2)]
|
|
config = SubplanConfig(execution_mode=ExecutionMode.SEQUENTIAL)
|
|
service = SubplanExecutionService(config=config, executor_fn=_success_executor)
|
|
result = service.execute_all(subplan_statuses=statuses, base_files={})
|
|
assert result.all_succeeded
|
|
assert result.total_duration_ms >= 0
|
|
assert len(result.statuses) == 2
|
|
assert len(result.failed_subplan_ids) == 0
|
|
print("exec-summary-ok")
|
|
|
|
|
|
def _retry_non_retriable() -> None:
|
|
"""Verify non-retriable error is not retried."""
|
|
|
|
def executor(status: SubplanStatus) -> SubplanExecutionOutput:
|
|
return SubplanExecutionOutput(
|
|
subplan_id=status.subplan_id,
|
|
success=False,
|
|
error="ConfigurationError: invalid config",
|
|
)
|
|
|
|
config = SubplanConfig(retry_failed=True, max_retries=2)
|
|
service = SubplanExecutionService(config=config, executor_fn=executor)
|
|
result = service.execute_all(subplan_statuses=[_make_status(_S1)], base_files={})
|
|
assert not result.all_succeeded
|
|
assert result.statuses[0].attempt_number == 1
|
|
print("retry-non-retriable-ok")
|
|
|
|
|
|
def _retry_exception_then_succeed() -> None:
|
|
"""Verify executor exception triggers retry and then succeeds."""
|
|
call_count: list[int] = [0]
|
|
|
|
def executor(status: SubplanStatus) -> SubplanExecutionOutput:
|
|
call_count[0] += 1
|
|
if call_count[0] <= 1:
|
|
raise RuntimeError("TimeoutError: simulated exception")
|
|
return SubplanExecutionOutput(
|
|
subplan_id=status.subplan_id,
|
|
success=True,
|
|
files={"src/main.py": "# recovered\n"},
|
|
files_changed=1,
|
|
)
|
|
|
|
config = SubplanConfig(retry_failed=True, max_retries=2)
|
|
service = SubplanExecutionService(config=config, executor_fn=executor)
|
|
result = service.execute_all(subplan_statuses=[_make_status(_S1)], base_files={})
|
|
assert result.all_succeeded
|
|
assert result.statuses[0].attempt_number == 2
|
|
print("retry-exception-ok")
|
|
|
|
|
|
def _merge_conflict_exec() -> None:
|
|
"""Verify merge conflict during execution marks not all succeeded."""
|
|
|
|
def executor(status: SubplanStatus) -> SubplanExecutionOutput:
|
|
return SubplanExecutionOutput(
|
|
subplan_id=status.subplan_id,
|
|
success=True,
|
|
files={"src/main.py": f"conflict-{status.subplan_id}\n"},
|
|
files_changed=1,
|
|
)
|
|
|
|
config = SubplanConfig(merge_strategy=SubplanMergeStrategy.FAIL_ON_CONFLICT)
|
|
service = SubplanExecutionService(config=config, executor_fn=executor)
|
|
result = service.execute_all(
|
|
subplan_statuses=[_make_status(_S1), _make_status(_S2)],
|
|
base_files={"src/main.py": "base\n"},
|
|
)
|
|
assert not result.all_succeeded
|
|
print("merge-conflict-exec-ok")
|
|
|
|
|
|
def main() -> None:
|
|
"""Dispatch command from sys.argv."""
|
|
if len(sys.argv) < 2:
|
|
raise SystemExit("Expected command argument")
|
|
command = sys.argv[1]
|
|
commands: dict[str, object] = {
|
|
"sequential-all": _sequential_all,
|
|
"sequential-failfast": _sequential_failfast,
|
|
"parallel-all": _parallel_all,
|
|
"dep-ordered": _dep_ordered,
|
|
"merge-git-clean": _merge_git_clean,
|
|
"merge-sequential": _merge_sequential,
|
|
"merge-last-wins": _merge_last_wins,
|
|
"merge-fail-conflict": _merge_fail_conflict,
|
|
"exec-merge-integration": _exec_merge_integration,
|
|
"validation-guards": _validation_guards,
|
|
"exec-summary": _exec_summary,
|
|
"retry-non-retriable": _retry_non_retriable,
|
|
"retry-exception": _retry_exception_then_succeed,
|
|
"merge-conflict-exec": _merge_conflict_exec,
|
|
}
|
|
if command not in commands:
|
|
raise SystemExit(f"Unknown command: {command}")
|
|
func = commands[command]
|
|
if callable(func):
|
|
func()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|