"""Autonomous Engineering Agent (AEA²) — Execution Planning Pipeline. Implements a deterministic seven-stage pipeline that transforms AdvisorReport recommendations into safe, reviewable ExecutionPlans: TaskDecomposer ↓ DependencyResolver ↓ ConflictDetector ↓ ParallelizationPlanner ↓ RiskAnalyzer ↓ ExecutionPlannerService (coordinator + persistence) No repository analysis, no LLM calls, no code modifications. """ from __future__ import annotations import json import logging import os import time import uuid from collections import defaultdict, deque from typing import Any, Dict, List, Optional, Set, Tuple from models.execution import ( ConflictReport, ExecutionBatch, ExecutionPlan, ExecutionTask, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Shared constants # --------------------------------------------------------------------------- _RISK_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} _EFFORT_HOURS: Dict[str, float] = { "< 2 hours": 1.5, "Half day": 4.0, "1 day": 8.0, "2–3 days": 20.0, "1 week": 40.0, "Multi-week": 80.0, "unknown": 8.0, } # Task subtypes generated per recommendation category _CATEGORY_SUBTASKS: Dict[str, List[Tuple[str, str, bool]]] = { # (title_suffix, description, is_rollback_point) "security": [ ( "Audit vulnerable dependency", "Identify and document the vulnerable package and affected versions.", False, ), ( "Apply security patch", "Upgrade or replace the vulnerable dependency.", False, ), ( "Verify patch", "Run security scans and integration tests to confirm resolution.", True, ), ], "architecture": [ ( "Identify refactoring boundaries", "Map the module boundaries that will change.", False, ), ( "Refactor module structure", "Apply the structural change while preserving external interfaces.", False, ), ( "Update integration points", "Adjust all callers/consumers of the modified module.", False, ), ( "Verify architecture compliance", "Run architecture linting and integration tests.", True, ), ], "performance": [ ("Profile hot-path", "Measure current performance baseline.", False), ( "Implement optimization", "Apply the targeted performance improvement.", False, ), ( "Benchmark and verify", "Confirm improvement against baseline and check for regressions.", True, ), ], "dependency": [ ( "Audit outdated dependencies", "List all affected packages and their current/target versions.", False, ), ("Upgrade dependencies", "Apply version upgrades incrementally.", False), ( "Run compatibility tests", "Verify no breaking changes were introduced.", True, ), ], "complexity": [ ( "Identify complex units", "Mark functions and modules exceeding complexity thresholds.", False, ), ( "Decompose complex logic", "Break large units into smaller, testable functions.", False, ), ("Verify test coverage", "Ensure decomposed units are covered by tests.", True), ], "dead_code": [ ( "Identify unused declarations", "Locate unreferenced symbols across the codebase.", False, ), ("Remove dead code", "Delete confirmed-unused declarations.", False), ( "Verify build integrity", "Confirm the project builds and tests pass after removal.", True, ), ], "documentation": [ ( "Audit missing documentation", "Identify undocumented public APIs and modules.", False, ), ( "Write documentation", "Add docstrings, README sections, or architecture notes.", True, ), ], "testing": [ ( "Audit test coverage gaps", "Identify critical code paths lacking test coverage.", False, ), ( "Write missing tests", "Implement unit and integration tests for uncovered paths.", False, ), ( "Verify coverage targets", "Confirm coverage meets the defined threshold.", True, ), ], "general": [ ( "Investigate issue", "Understand the root cause and define the remediation approach.", False, ), ("Implement remediation", "Apply the fix or improvement.", False), ( "Verify resolution", "Confirm the issue is resolved and no regressions were introduced.", True, ), ], } # --------------------------------------------------------------------------- # 1. Task Decomposer # --------------------------------------------------------------------------- class TaskDecomposer: """Splits AdvisorRecommendations into discrete, ordered ExecutionTasks.""" def _subtask_id(self) -> str: return str(uuid.uuid4()) def decompose(self, recommendations: List[Dict[str, Any]]) -> List[ExecutionTask]: """Returns a flat list of ExecutionTasks from all recommendations.""" all_tasks: List[ExecutionTask] = [] for rec in recommendations: category = rec.get("category", "general") subtask_templates = _CATEGORY_SUBTASKS.get( category, _CATEGORY_SUBTASKS["general"] ) rec_id = rec.get("id", "") affected = list(rec.get("affected_entities", [])) effort = rec.get("estimated_effort", "unknown") prev_id: Optional[str] = None for suffix, description, is_rollback in subtask_templates: task_id = self._subtask_id() task = ExecutionTask( id=task_id, recommendation_id=rec_id, title=f"{rec.get('title', 'Task')} — {suffix}", description=description, category=category, dependencies=[prev_id] if prev_id else [], estimated_effort=effort, affected_entities=affected, rollback_checkpoint=is_rollback, ) all_tasks.append(task) prev_id = task_id return all_tasks # --------------------------------------------------------------------------- # 2. Dependency Resolver # --------------------------------------------------------------------------- class DependencyResolver: """Builds and validates the task dependency DAG. Ensures no circular dependencies using topological sort (Kahn's algorithm). """ def _topological_sort(self, tasks: List[ExecutionTask]) -> List[ExecutionTask]: """Returns tasks in topological execution order.""" id_map = {t.id: t for t in tasks} in_degree: Dict[str, int] = {t.id: 0 for t in tasks} adjacency: Dict[str, List[str]] = defaultdict(list) for task in tasks: for dep_id in task.dependencies: if dep_id in id_map: adjacency[dep_id].append(task.id) in_degree[task.id] += 1 queue = deque(tid for tid, deg in in_degree.items() if deg == 0) ordered: List[ExecutionTask] = [] while queue: tid = queue.popleft() ordered.append(id_map[tid]) for neighbour in adjacency[tid]: in_degree[neighbour] -= 1 if in_degree[neighbour] == 0: queue.append(neighbour) if len(ordered) != len(tasks): # Circular dependency detected — return original order with a warning logger.warning( "Circular dependency detected; returning tasks in original order." ) return tasks return ordered def resolve(self, tasks: List[ExecutionTask]) -> List[ExecutionTask]: """Returns tasks in valid dependency order.""" return self._topological_sort(tasks) def compute_critical_path(self, tasks: List[ExecutionTask]) -> List[str]: """Returns the ordered task IDs forming the longest dependency chain.""" id_map = {t.id: t for t in tasks} # Memoised DFS: returns the longest chain length ending at each node memo: Dict[str, int] = {} def chain_len(tid: str) -> int: if tid in memo: return memo[tid] task = id_map.get(tid) if not task or not task.dependencies: memo[tid] = 1 return 1 best = 1 + max( (chain_len(dep) for dep in task.dependencies if dep in id_map), default=0, ) memo[tid] = best return best for t in tasks: chain_len(t.id) if not memo: return [] # Walk back from the deepest node to reconstruct the path deepest = max(memo, key=memo.__getitem__) path: List[str] = [] current = deepest while current: path.append(current) task = id_map.get(current) if not task or not task.dependencies: break current = max( (dep for dep in task.dependencies if dep in id_map), key=lambda d: memo.get(d, 0), default=None, # type: ignore[arg-type] ) return list(reversed(path)) # --------------------------------------------------------------------------- # 3. Conflict Detector # --------------------------------------------------------------------------- class ConflictDetector: """Detects execution conflicts between tasks that would otherwise run concurrently.""" def detect(self, tasks: List[ExecutionTask]) -> List[ConflictReport]: """Returns all detected conflicts between task pairs.""" conflicts: List[ConflictReport] = [] n = len(tasks) for i in range(n): for j in range(i + 1, n): a, b = tasks[i], tasks[j] conflict = self._check_pair(a, b) if conflict: conflicts.append(conflict) return conflicts def _check_pair( self, a: ExecutionTask, b: ExecutionTask ) -> Optional[ConflictReport]: """Checks two tasks for conflicts. Returns ConflictReport or None.""" # Skip tasks that already have an explicit dependency ordering if b.id in a.dependencies or a.id in b.dependencies: return None # File collision: both tasks modify the same entities shared_entities = set(a.affected_entities) & set(b.affected_entities) if shared_entities and a.recommendation_id != b.recommendation_id: return ConflictReport( task_a_id=a.id, task_b_id=b.id, conflict_type="file_collision", description=( f"Both tasks modify: {', '.join(sorted(shared_entities))}. " "Concurrent modification may produce merge conflicts." ), resolution="serialize", ) # Same-category concurrent modifications risk module ownership violations if ( a.category == b.category and a.category in ("security", "architecture", "dependency") and a.recommendation_id != b.recommendation_id and not shared_entities # Already reported above if entities overlap ): return ConflictReport( task_a_id=a.id, task_b_id=b.id, conflict_type="module_ownership", description=( f"Both tasks make '{a.category}' changes concurrently. " "Risk of overlapping scope or conflicting conventions." ), resolution="manual_review", ) return None # --------------------------------------------------------------------------- # 4. Parallelization Planner # --------------------------------------------------------------------------- class ParallelizationPlanner: """Groups topologically-ordered tasks into parallel execution batches.""" _BATCH_TITLES = { 1: "Batch 1 — Security & Critical Fixes", 2: "Batch 2 — Architecture & Structure", 3: "Batch 3 — Performance & Complexity", 4: "Batch 4 — Quality & Maintainability", } _CATEGORY_TO_BATCH = { "security": 1, "dependency": 1, "architecture": 2, "dead_code": 2, "performance": 3, "complexity": 3, "testing": 4, "documentation": 4, "general": 4, } def _effort_hours(self, tasks: List[ExecutionTask]) -> float: return sum(_EFFORT_HOURS.get(t.estimated_effort, 8.0) for t in tasks) def _hours_to_label(self, hours: float) -> str: for threshold, label in [ (1.5, "< 2 hours"), (4.0, "Half day"), (8.0, "1 day"), (20.0, "2–3 days"), (40.0, "1 week"), ]: if hours <= threshold: return label return "Multi-week" def plan(self, tasks: List[ExecutionTask]) -> List[ExecutionBatch]: """Assigns tasks to ordered batches respecting dependency ordering.""" id_map = {t.id: t for t in tasks} # Assign tasks to buckets buckets: Dict[int, List[ExecutionTask]] = defaultdict(list) for task in tasks: batch_num = self._CATEGORY_TO_BATCH.get(task.category, 4) # Tasks with dependencies in an earlier batch must go to that batch or later if task.dependencies: max_dep_batch = max( ( self._CATEGORY_TO_BATCH.get( id_map[dep].category if dep in id_map else "general", 4 ) for dep in task.dependencies if dep in id_map ), default=0, ) batch_num = max(batch_num, max_dep_batch) buckets[batch_num].append(task) batches: List[ExecutionBatch] = [] for order, (num, bucket_tasks) in enumerate(sorted(buckets.items()), start=1): # Annotate parallel_with relationships within the same batch bucket_ids = [t.id for t in bucket_tasks] for task in bucket_tasks: task.parallel_with = [ tid for tid in bucket_ids if tid != task.id and tid not in task.dependencies ] total_hours = self._effort_hours(bucket_tasks) batches.append( ExecutionBatch( id=str(uuid.uuid4()), order=order, title=self._BATCH_TITLES.get(num, f"Batch {order}"), tasks=bucket_tasks, parallel=len(bucket_tasks) > 1, estimated_total_effort=self._hours_to_label(total_hours), ) ) return batches # --------------------------------------------------------------------------- # 5. Risk Analyzer # --------------------------------------------------------------------------- class RiskAnalyzer: """Assigns deterministic risk levels to ExecutionTasks.""" _CATEGORY_BASE_RISK = { "security": "high", "dependency": "medium", "architecture": "high", "dead_code": "low", "performance": "medium", "complexity": "medium", "documentation": "low", "testing": "low", "general": "medium", } _RISK_ESCALATION_THRESHOLDS = { "entities_high": 5, # Many affected entities → escalate "dep_depth_high": 3, # Deep dependency chain → escalate } def _escalate(self, base: str) -> str: levels = ["low", "medium", "high", "critical"] idx = levels.index(base) if base in levels else 1 return levels[min(idx + 1, len(levels) - 1)] def _risk_rationale(self, task: ExecutionTask, risk: str, dep_depth: int) -> str: parts = [ f"Category '{task.category}' baseline risk: {self._CATEGORY_BASE_RISK.get(task.category, 'medium')}." ] if ( len(task.affected_entities) >= self._RISK_ESCALATION_THRESHOLDS["entities_high"] ): parts.append(f"Affects {len(task.affected_entities)} entities (escalated).") if dep_depth >= self._RISK_ESCALATION_THRESHOLDS["dep_depth_high"]: parts.append(f"Dependency depth {dep_depth} (escalated).") return " ".join(parts) def analyze( self, tasks: List[ExecutionTask], dep_depths: Optional[Dict[str, int]] = None, ) -> List[ExecutionTask]: """Assigns risk levels to all tasks in place and returns them.""" dep_depths = dep_depths or {} for task in tasks: base_risk = self._CATEGORY_BASE_RISK.get(task.category, "medium") risk = base_risk # Escalate for many affected entities if ( len(task.affected_entities) >= self._RISK_ESCALATION_THRESHOLDS["entities_high"] ): risk = self._escalate(risk) # Escalate for deep dependency chains depth = dep_depths.get(task.id, len(task.dependencies)) if depth >= self._RISK_ESCALATION_THRESHOLDS["dep_depth_high"]: risk = self._escalate(risk) task.risk = risk task.risk_rationale = self._risk_rationale(task, risk, depth) return tasks # --------------------------------------------------------------------------- # 6. Execution Planner Service # --------------------------------------------------------------------------- class ExecutionPlannerService: """Coordinates the full AEA² pipeline and manages plan persistence.""" def __init__(self, base_dir: Optional[str] = None) -> None: self.base_dir = base_dir or os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "execution_plans", ) self.decomposer = TaskDecomposer() self.resolver = DependencyResolver() self.conflict_detector = ConflictDetector() self.parallelization_planner = ParallelizationPlanner() self.risk_analyzer = RiskAnalyzer() # ------------------------------------------------------------------ # Persistence # ------------------------------------------------------------------ def _repo_dir(self, repo_name: str) -> str: safe = repo_name.replace("/", "_").replace("\\", "_") path = os.path.join(self.base_dir, safe) os.makedirs(path, exist_ok=True) return path def _save(self, plan: ExecutionPlan) -> None: dir_path = self._repo_dir(plan.repository) ts_path = os.path.join(dir_path, f"{int(plan.generated_at)}.json") latest_path = os.path.join(dir_path, "latest.json") payload = plan.model_dump() for path in (ts_path, latest_path): with open(path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2) logger.info("Saved ExecutionPlan for '%s' to %s", plan.repository, latest_path) def load_latest(self, repo_name: str) -> Optional[ExecutionPlan]: path = os.path.join(self._repo_dir(repo_name), "latest.json") if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as fh: return ExecutionPlan.model_validate(json.load(fh)) except Exception as exc: logger.error("Failed to load ExecutionPlan: %s", exc) return None # ------------------------------------------------------------------ # Statistics # ------------------------------------------------------------------ def _compute_stats( self, tasks: List[ExecutionTask], batches: List[ExecutionBatch], conflicts: List[ConflictReport], ) -> Dict[str, Any]: by_risk: Dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0} by_category: Dict[str, int] = {} for t in tasks: by_risk[t.risk] = by_risk.get(t.risk, 0) + 1 by_category[t.category] = by_category.get(t.category, 0) + 1 return { "total_tasks": len(tasks), "total_batches": len(batches), "total_conflicts": len(conflicts), "rollback_checkpoints": sum(1 for t in tasks if t.rollback_checkpoint), "by_risk": by_risk, "by_category": by_category, } # ------------------------------------------------------------------ # Pipeline # ------------------------------------------------------------------ def plan( self, repo_name: str, advisor_report: Dict[str, Any], ) -> ExecutionPlan: """Runs the full AEA² pipeline and returns a persisted ExecutionPlan.""" generated_at = time.time() recommendations = advisor_report.get("recommendations", []) # Stage 1 — Decompose recommendations into tasks tasks = self.decomposer.decompose(recommendations) # Stage 2 — Resolve dependencies (topological sort, detect cycles) ordered_tasks = self.resolver.resolve(tasks) # Stage 3 — Compute critical path critical_path = self.resolver.compute_critical_path(ordered_tasks) # Stage 4 — Detect conflicts conflicts = self.conflict_detector.detect(ordered_tasks) # Serialise conflicting task pairs (add dependency to resolve ordering) conflict_pairs: Set[Tuple[str, str]] = { (c.task_a_id, c.task_b_id) for c in conflicts if c.resolution == "serialize" } id_map = {t.id: t for t in ordered_tasks} for a_id, b_id in conflict_pairs: if b_id in id_map and a_id not in id_map[b_id].dependencies: id_map[b_id].dependencies.append(a_id) # Stage 5 — Assign risk levels dep_depths: Dict[str, int] = {t.id: len(t.dependencies) for t in ordered_tasks} risk_scored = self.risk_analyzer.analyze(ordered_tasks, dep_depths) # Stage 6 — Parallelization and batching batches = self.parallelization_planner.plan(risk_scored) # Collect rollback points rollback_points = [t.id for t in risk_scored if t.rollback_checkpoint] # Build plan plan = ExecutionPlan( id=str(uuid.uuid4()), repository=repo_name, generated_at=generated_at, advisor_report_timestamp=advisor_report.get("generated_at"), batches=batches, critical_path=critical_path, rollback_points=rollback_points, conflicts=conflicts, statistics=self._compute_stats(risk_scored, batches, conflicts), metadata={ "pipeline_stages": [ "TaskDecomposer", "DependencyResolver", "ConflictDetector", "ParallelizationPlanner", "RiskAnalyzer", ], "source_recommendations": len(recommendations), }, ) self._save(plan) return plan