423 lines
14 KiB
Python
423 lines
14 KiB
Python
|
|
"""
|
||
|
|
Main orchestrator for AI agent execution.
|
||
|
|
Manages the development cycle: read tasks -> execute -> test -> commit -> repeat
|
||
|
|
"""
|
||
|
|
import sys
|
||
|
|
import subprocess
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
from config import AgentConfig, get_test_command, get_lint_command
|
||
|
|
from task_manager import TaskManager, GlobalLock, Task
|
||
|
|
from cursor_cli_wrapper import CursorCLI, CursorResultStatus
|
||
|
|
|
||
|
|
|
||
|
|
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:
|
||
|
|
"""
|
||
|
|
Main execution loop.
|
||
|
|
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"🎯 Max iterations: {self.config.max_iterations}")
|
||
|
|
|
||
|
|
# 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:
|
||
|
|
# Main development loop
|
||
|
|
while True:
|
||
|
|
state = self.task_manager.load_state()
|
||
|
|
|
||
|
|
# Check iteration limit
|
||
|
|
if state.iteration_count >= self.config.max_iterations:
|
||
|
|
print(f"\n⏹️ Reached maximum iterations ({self.config.max_iterations})")
|
||
|
|
break
|
||
|
|
|
||
|
|
# Get next task
|
||
|
|
next_task = self.task_manager.get_next_task()
|
||
|
|
|
||
|
|
if not next_task:
|
||
|
|
print("\n✅ All tasks completed!")
|
||
|
|
self.task_manager.reset_state()
|
||
|
|
break
|
||
|
|
|
||
|
|
print(f"\n{'='*80}")
|
||
|
|
print(f"📋 Task {state.iteration_count + 1}/{self.config.max_iterations}: {next_task.id}")
|
||
|
|
print(f"📝 {next_task.title}")
|
||
|
|
print(f"⏱️ Estimated: {next_task.estimated_hours}h")
|
||
|
|
print(f"{'='*80}\n")
|
||
|
|
|
||
|
|
# Start task
|
||
|
|
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"
|
||
|
|
)
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
# 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)
|
||
|
|
else:
|
||
|
|
# Task failed
|
||
|
|
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")
|
||
|
|
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)
|
||
|
|
else:
|
||
|
|
print(f"\n⚠️ Task {next_task.id} failed, will retry ({retry_count}/{self.config.max_retries})")
|
||
|
|
|
||
|
|
finally:
|
||
|
|
# Release global lock if held
|
||
|
|
if needs_global_lock:
|
||
|
|
self.global_lock.release(self.config.component)
|
||
|
|
|
||
|
|
# Small delay between tasks
|
||
|
|
time.sleep(2)
|
||
|
|
|
||
|
|
print("\n🎉 Agent execution completed")
|
||
|
|
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" - Tool calls: {result.tool_calls}")
|
||
|
|
|
||
|
|
# Pull latest changes before committing
|
||
|
|
print("\n🔄 Pulling latest changes...")
|
||
|
|
self._git_pull()
|
||
|
|
|
||
|
|
# Run linter
|
||
|
|
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("\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 _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 <component>")
|
||
|
|
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()
|
||
|
|
|