""" Main orchestrator for AI agent execution. Executes a single task per run: read task -> execute -> test -> commit """ import sys import subprocess import time from pathlib import Path from datetime import datetime, timezone from typing import Optional, List from config import AgentConfig, get_test_command, get_lint_command from task_manager import TaskManager, GlobalLock, Task from cursor_cli_wrapper import CursorCLI, CursorResultStatus from planning_agent import PlanningAgent class AgentOrchestrator: """Orchestrates the agent development cycle.""" def __init__(self, config: AgentConfig): self.config = config self.task_manager = TaskManager( config.task_list_path, config.agent_state_path ) self.global_lock = GlobalLock(config.global_lock_path) self.cursor_cli = CursorCLI( project_root=config.project_root, api_key=config.cursor_api_key, model=config.cursor_model, verbose=True ) def run(self) -> int: """ Execute a single task from the task list. Returns: 0 on success, 1 on error """ print(f"๐Ÿš€ Starting AI Agent for component: {self.config.component}") print(f"๐Ÿ“ Project root: {self.config.project_root}") print(f"๐ŸŽฏ Executing one task per run") # Check if another agent is running if self.task_manager.is_agent_running(): print(f"โš ๏ธ Another agent is already running for {self.config.component}") print("Exiting to avoid conflicts.") return 0 try: state = self.task_manager.load_state() # Reset stale in_progress tasks (tasks that were left in_progress from previous runs) self.task_manager.reset_stale_in_progress_tasks(state.current_task_id) # Check iteration limit if state.iteration_count >= self.config.max_iterations: print(f"\nโน๏ธ Reached maximum iterations ({self.config.max_iterations})") return 0 # Get next task next_task = self.task_manager.get_next_task() if not next_task: print("\nโœ… All tasks completed or no pending tasks!") # Trigger planning agent to generate new tasks print("\n๐ŸŽฏ No tasks available, triggering planning agent...") planning_agent = PlanningAgent(self.config) planning_result = planning_agent.run() if planning_result == 0: print("\nโœ… Planning agent completed successfully") print(" New tasks have been generated. Checking for available tasks...") # Try to get next task again after planning next_task = self.task_manager.get_next_task() if next_task: print(f"\n๐Ÿ“‹ Found new task: {next_task.id} - {next_task.title}") print(" Proceeding to execute the new task...") # Continue with task execution (don't return, fall through to task execution) else: print("\nโš ๏ธ Planning agent completed but no new tasks were generated") print(" Agent will exit. You may need to manually create tasks.") self.task_manager.reset_state() return 0 else: print("\nโš ๏ธ Planning agent failed") print(" Agent will exit. You may need to manually trigger planning or create tasks") self.task_manager.reset_state() return 0 # At this point we should have a task to execute # Continue with task execution print(f"\n{'='*80}") print(f"๐Ÿ“‹ Task: {next_task.id}") print(f"๐Ÿ“ {next_task.title}") print(f"โฑ๏ธ Estimated: {next_task.estimated_hours}h") print(f"{'='*80}\n") # Start task (only if not already in progress) if next_task.status != "in_progress": self.task_manager.start_task(next_task) # Check if task needs global lock (modifies common package) needs_global_lock = self._needs_global_lock(next_task) if needs_global_lock: if not self._acquire_global_lock(): print("โš ๏ธ Cannot acquire global lock, skipping task") self.task_manager.mark_task_skipped( next_task.id, "Global lock not available" ) return 0 try: # Retry loop: keep trying the same task until success or max retries while True: # Get current retry count current_state = self.task_manager.load_state() retry_count = current_state.retry_count if retry_count > 0: print(f"\n๐Ÿ”„ Retry attempt {retry_count}/{self.config.max_retries} for task {next_task.id}") # Execute task success = self._execute_task(next_task) if success: # Task completed successfully print(f"\nโœ… Task {next_task.id} completed successfully") self.task_manager.mark_task_completed(next_task.id) break else: # Task failed, increment retry count retry_count = self.task_manager.increment_retry() if retry_count >= self.config.max_retries: print(f"\nโŒ Task {next_task.id} failed after {retry_count} retries") # Try to break down the task into subtasks print(f"\n๐Ÿ”€ Attempting to break down task {next_task.id} into subtasks...") planning_agent = PlanningAgent(self.config) failure_reason = f"Failed after {retry_count} retry attempts. Task may be too complex." breakdown_success = planning_agent.break_down_task( next_task, failure_reason ) if breakdown_success: print(f"\nโœ… Task {next_task.id} successfully broken down into subtasks") print(" Subtasks have been added to task_list.json") # Mark original task as failed self.task_manager.mark_task_failed( next_task.id, f"Failed after {retry_count} retries. Broken down into subtasks." ) else: print(f"\nโš ๏ธ Failed to break down task {next_task.id}") print(" Marking task as failed without subtasks") self.task_manager.mark_task_failed( next_task.id, f"Failed after {retry_count} retries" ) # Create GitHub issue self._create_issue_for_failed_task(next_task) break else: print(f"\nโš ๏ธ Task {next_task.id} failed, retrying ({retry_count}/{self.config.max_retries})") print("Waiting before retry...") time.sleep(5) # Small delay before retry finally: # Release global lock if held if needs_global_lock: self.global_lock.release(self.config.component) print("\n๐ŸŽ‰ Agent execution completed (one task)") return 0 except KeyboardInterrupt: print("\nโš ๏ธ Interrupted by user") self.task_manager.reset_state() return 1 except Exception as e: print(f"\nโŒ Fatal error: {e}") import traceback traceback.print_exc() return 1 def _needs_global_lock(self, task: Task) -> bool: """Check if task needs global lock (modifies shared resources).""" # Check if any files to modify are in mnemo_cards_common for file_path in task.files_to_modify: if "mnemo_cards_common" in file_path: return True return False def _acquire_global_lock(self, timeout_minutes: int = 30) -> bool: """Try to acquire global lock with timeout.""" start_time = time.time() while True: if self.global_lock.acquire( self.config.component, f"Working on {self.config.component}" ): print("๐Ÿ”’ Acquired global lock") return True elapsed_minutes = (time.time() - start_time) / 60 if elapsed_minutes >= timeout_minutes: return False print(f"โณ Waiting for global lock... ({elapsed_minutes:.1f}/{timeout_minutes} min)") time.sleep(30) # Check every 30 seconds def _execute_task(self, task: Task) -> bool: """ Execute a single task. Returns: True if successful, False otherwise """ # Load prompt template prompt = self._build_task_prompt(task) # Run cursor agent print("๐Ÿค– Running Cursor Agent...") result = self.cursor_cli.run_agent( task_description=prompt, force=True, max_iterations=5 ) if result.status != CursorResultStatus.SUCCESS: print(f"โŒ Cursor agent failed: {result.error}") return False print(f"\n๐Ÿ“Š Agent completed:") print(f" - Files created: {len(result.files_created)}") print(f" - Files modified: {len(result.files_modified)}") print(f" - Files read: {len(result.files_read)}") print(f" - Tool calls: {result.tool_calls}") # Check if any files were actually changed if len(result.files_created) == 0 and len(result.files_modified) == 0: print("\nโš ๏ธ WARNING: Agent completed but no files were created or modified!") print(f" Agent read {len(result.files_read)} file(s) but made no changes.") print(" This might indicate:") print(" - Task was already completed") print(" - Agent only analyzed files without making changes") print(" - Agent encountered an issue but didn't report it") # Check git status to see if there are any uncommitted changes git_changes = self._check_git_changes() if git_changes: print(f"\n However, git shows {len(git_changes)} uncommitted change(s):") for change in git_changes[:5]: # Show first 5 print(f" - {change}") if len(git_changes) > 5: print(f" ... and {len(git_changes) - 5} more") else: print("\n Git confirms: no changes detected in working directory.") print(" Task may need to be retried or task description clarified.") # Pull latest changes before committing print("\n๐Ÿ”„ Pulling latest changes...") self._git_pull() # Run linter print("๐Ÿ” Linter is disabled for now") # print("\n๐Ÿ” Running linter...") # if not self._run_lint(): # print("โš ๏ธ Linter found issues") # # Don't fail, cursor can fix in retry # return False # Run tests print("Tests are disabled for now") # print("\n๐Ÿงช Running tests...") # if not self._run_tests(): # print("โŒ Tests failed") # return False # Commit changes print("\n๐Ÿ’พ Committing changes...") commit_message = self._build_commit_message(task, result) if not self._git_commit(commit_message): print("โš ๏ธ No changes to commit") # Push changes print("\n๐Ÿ“ค Pushing changes...") self._git_push() return True def _build_task_prompt(self, task: Task) -> str: """Build comprehensive prompt for the agent.""" # Load development prompt template prompt_file = self.config.prompts_dir / "development_prompt.md" if prompt_file.exists(): with open(prompt_file, 'r') as f: template = f.read() else: template = "You are an AI software engineer. Complete the following task:\n\n" # Add task details prompt = f"""{template} ## TASK: {task.id} - {task.title} ### Priority: {task.priority.upper()} ### Description: {task.description} ### Acceptance Criteria: """ for i, criterion in enumerate(task.acceptance_criteria, 1): prompt += f"{i}. {criterion}\n" prompt += f""" ### Files to Modify: """ for file_path in task.files_to_modify: prompt += f"- {file_path}\n" prompt += f""" ### Component: {task.component} ### Working Directory: {self.config.component_root} --- Complete this task following clean architecture principles and project conventions. Write comprehensive unit tests for all new functionality. Make sure all existing tests continue to pass. """ return prompt def _build_commit_message(self, task: Task, result) -> str: """Build descriptive commit message.""" message = f"""feat({self.config.component}): {task.title} Task ID: {task.id} Priority: {task.priority} Changes: """ if result.files_created: message += f"- Created {len(result.files_created)} file(s)\n" for file in result.files_created[:5]: # Limit to 5 message += f" - {file}\n" if result.files_modified: message += f"- Modified {len(result.files_modified)} file(s)\n" for file in result.files_modified[:5]: # Limit to 5 message += f" - {file}\n" message += f"\nCompleted by: AI Agent\n" message += f"Duration: {result.duration_ms}ms\n" return message def _run_lint(self) -> bool: """Run linter for component.""" cmd = get_lint_command(self.config.component) result = subprocess.run( cmd, shell=True, cwd=self.config.project_root, capture_output=True, text=True ) if result.returncode == 0: print("โœ… Linter passed") return True else: print(f"โš ๏ธ Linter output:\n{result.stdout}") return False def _run_tests(self) -> bool: """Run tests for component.""" cmd = get_test_command(self.config.component) result = subprocess.run( cmd, shell=True, cwd=self.config.project_root, capture_output=True, text=True ) if result.returncode == 0: print("โœ… All tests passed") return True else: print(f"โŒ Test output:\n{result.stdout}") return False def _check_git_changes(self) -> List[str]: """Check for uncommitted changes in git.""" # Check status for modified, added, deleted files result = subprocess.run( ["git", "status", "--porcelain"], cwd=self.config.project_root, capture_output=True, text=True ) if result.returncode != 0: return [] changes = [] for line in result.stdout.strip().split('\n'): if line.strip(): # Format: "XY filename" where X is staging area, Y is working tree # We care about files that are modified (M), added (A), deleted (D), or renamed (R) status = line[:2] filename = line[3:].strip() if any(c in status for c in ['M', 'A', 'D', 'R']): changes.append(filename) return changes def _git_pull(self) -> bool: """Pull latest changes from remote.""" result = subprocess.run( ["git", "pull", "--rebase"], cwd=self.config.project_root, capture_output=True, text=True ) return result.returncode == 0 def _git_commit(self, message: str) -> bool: """Commit changes.""" # Add all changes subprocess.run( ["git", "add", "-A"], cwd=self.config.project_root ) # Commit result = subprocess.run( ["git", "commit", "-m", message], cwd=self.config.project_root, capture_output=True, text=True ) if result.returncode == 0: # Get commit hash commit_hash = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=self.config.project_root, capture_output=True, text=True ).stdout.strip() # Update state with commit hash state = self.task_manager.load_state() state.last_commit = commit_hash self.task_manager.save_state(state) print(f"โœ… Committed: {commit_hash[:8]}") return True else: # No changes or error return False def _git_push(self) -> bool: """Push changes to remote.""" result = subprocess.run( ["git", "push"], cwd=self.config.project_root, capture_output=True, text=True ) if result.returncode == 0: print("โœ… Pushed to remote") return True else: print(f"โš ๏ธ Push failed: {result.stderr}") return False def _create_issue_for_failed_task(self, task: Task) -> None: """Create GitHub/Forgejo issue for failed task.""" # TODO: Implement GitHub/Forgejo API integration print(f"\n๐Ÿ“‹ TODO: Create issue for failed task {task.id}") print(f" Title: Failed: {task.title}") print(f" Description: Task failed after {self.config.max_retries} retries") def main(): """Main entry point.""" if len(sys.argv) < 2: print("Usage: python agent_orchestrator.py ") print("Components: web_v2, backend, common") sys.exit(1) component = sys.argv[1] # Load configuration config = AgentConfig.from_env(component) try: config.validate() except ValueError as e: print(f"โŒ Configuration error: {e}") sys.exit(1) # Create orchestrator and run orchestrator = AgentOrchestrator(config) exit_code = orchestrator.run() sys.exit(exit_code) if __name__ == "__main__": main()