mnemo_cards/tools/agent/agent_orchestrator.py

995 lines
38 KiB
Python
Raw Permalink Normal View History

2025-11-20 21:28:55 +00:00
"""
Main orchestrator for AI agent execution.
2025-11-20 22:49:07 +00:00
Executes a single task per run: read task -> execute -> test -> commit
2025-11-20 21:28:55 +00:00
"""
import sys
import subprocess
import time
2025-11-21 11:13:03 +00:00
import json
import re
import urllib.request
import urllib.error
2025-11-20 21:28:55 +00:00
from pathlib import Path
from datetime import datetime, timezone
2025-11-21 11:13:03 +00:00
from typing import Optional, List, Tuple
2025-11-20 21:28:55 +00:00
from config import AgentConfig, get_test_command, get_lint_command
from task_manager import TaskManager, GlobalLock, Task
from cursor_cli_wrapper import CursorCLI, CursorResultStatus
2025-11-20 23:56:30 +00:00
from planning_agent import PlanningAgent
2025-11-20 21:28:55 +00:00
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:
"""
2025-11-20 22:49:07 +00:00
Execute a single task from the task list.
2025-11-20 21:28:55 +00:00
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}")
2025-11-20 22:49:07 +00:00
print(f"🎯 Executing one task per run")
2025-11-20 21:28:55 +00:00
# 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:
2025-11-20 22:49:07 +00:00
state = self.task_manager.load_state()
2025-11-21 07:18:15 +00:00
# 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)
2025-11-20 22:49:07 +00:00
# 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:
2025-11-21 07:18:15 +00:00
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
2025-11-20 22:49:07 +00:00
2025-11-21 07:18:15 +00:00
# At this point we should have a task to execute
# Continue with task execution
2025-11-20 22:49:07 +00:00
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")
2025-11-20 23:05:04 +00:00
# Start task (only if not already in progress)
if next_task.status != "in_progress":
self.task_manager.start_task(next_task)
2025-11-20 22:49:07 +00:00
# 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:
2025-11-20 23:05:04 +00:00
# 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
2025-11-20 22:49:07 +00:00
2025-11-20 23:05:04 +00:00
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
2025-11-20 22:49:07 +00:00
else:
2025-11-20 23:05:04 +00:00
# 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")
2025-11-20 23:56:30 +00:00
# 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
2025-11-20 23:05:04 +00:00
)
2025-11-20 23:56:30 +00:00
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)
2025-11-20 23:05:04 +00:00
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
2025-11-20 22:49:07 +00:00
finally:
# Release global lock if held
if needs_global_lock:
self.global_lock.release(self.config.component)
2025-11-20 21:28:55 +00:00
2025-11-20 22:49:07 +00:00
print("\n🎉 Agent execution completed (one task)")
2025-11-20 21:28:55 +00:00
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)}")
2025-11-20 23:56:30 +00:00
print(f" - Files read: {len(result.files_read)}")
2025-11-20 21:28:55 +00:00
print(f" - Tool calls: {result.tool_calls}")
2025-11-20 23:56:30 +00:00
# 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.")
2025-11-20 21:28:55 +00:00
# Pull latest changes before committing
print("\n🔄 Pulling latest changes...")
self._git_pull()
# Run linter
2025-11-21 00:11:57 +00:00
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
2025-11-20 21:28:55 +00:00
# Run tests
2025-11-21 00:11:57 +00:00
print("Tests are disabled for now")
# print("\n🧪 Running tests...")
# if not self._run_tests():
# print("❌ Tests failed")
# return False
2025-11-20 21:28:55 +00:00
# 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")
2025-11-21 07:56:05 +00:00
# Push changes (with pull before push to avoid conflicts)
2025-11-21 11:13:03 +00:00
# If push fails, _git_push() will automatically create a PR
2025-11-20 21:28:55 +00:00
print("\n📤 Pushing changes...")
2025-11-21 11:13:03 +00:00
push_success = self._git_push()
if not push_success:
print("⚠️ Push failed, attempting to create PR...")
# _git_push() already handles PR creation, but if it still failed,
# we'll continue anyway as the changes are committed locally
print("⚠️ Changes are committed locally but not pushed")
print(" You may need to manually push or create a PR")
2025-11-20 21:28:55 +00:00
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
2025-11-20 23:56:30 +00:00
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
2025-11-20 21:28:55 +00:00
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
2025-11-21 11:13:03 +00:00
def _get_git_repo_info(self) -> Tuple[Optional[str], Optional[str]]:
"""Get repository owner and name from git remote."""
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode != 0:
return None, None
remote_url = result.stdout.strip()
# Parse different URL formats:
# https://forgejo.example.com/owner/repo.git
# https://forgejo.example.com/owner/repo
# git@forgejo.example.com:owner/repo.git
# https://github.com/owner/repo.git
patterns = [
r'https?://[^/]+/([^/]+)/([^/]+?)(?:\.git)?/?$',
r'git@[^:]+:([^/]+)/([^/]+?)(?:\.git)?$',
]
for pattern in patterns:
match = re.search(pattern, remote_url)
if match:
owner = match.group(1)
repo = match.group(2)
return owner, repo
return None, None
def _get_forgejo_api_url(self) -> Optional[str]:
"""Get Forgejo/GitHub API URL from git remote."""
if self.config.forgejo_api_url:
return self.config.forgejo_api_url
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode != 0:
return None
remote_url = result.stdout.strip()
# Extract base URL
# https://forgejo.example.com/owner/repo.git -> https://forgejo.example.com
# https://github.com/owner/repo.git -> https://api.github.com
# git@forgejo.example.com:owner/repo.git -> https://forgejo.example.com
if 'github.com' in remote_url:
return 'https://api.github.com'
# For Forgejo, extract the base URL
match = re.search(r'https?://([^/]+)', remote_url)
if match:
base_url = f"https://{match.group(1)}"
return f"{base_url}/api/v1"
match = re.search(r'git@([^:]+)', remote_url)
if match:
base_url = f"https://{match.group(1)}"
return f"{base_url}/api/v1"
return None
def _check_existing_pr(self, branch_name: str) -> Optional[str]:
"""Check if PR already exists for this branch. Returns PR URL if found."""
if not self.config.forgejo_token:
return None
owner, repo = self._get_git_repo_info()
if not owner or not repo:
return None
api_url = self._get_forgejo_api_url()
if not api_url:
return None
# List open PRs for this branch
pr_url = f"{api_url}/repos/{owner}/{repo}/pulls?head={owner}:{branch_name}&state=open"
try:
req = urllib.request.Request(
pr_url,
headers={
"Authorization": f"token {self.config.forgejo_token}",
"Accept": "application/json"
}
)
with urllib.request.urlopen(req) as response:
if response.status == 200:
prs = json.loads(response.read().decode('utf-8'))
if prs and len(prs) > 0:
return prs[0].get('html_url')
except Exception:
# If check fails, continue anyway
pass
return None
def _create_pull_request(self, task: Task, branch_name: str) -> bool:
"""Create a pull request via Forgejo/GitHub API."""
if not self.config.forgejo_token:
print("⚠️ FORGEJO_TOKEN not set, cannot create PR")
return False
owner, repo = self._get_git_repo_info()
if not owner or not repo:
print("⚠️ Could not determine repository owner/name from git remote")
return False
api_url = self._get_forgejo_api_url()
if not api_url:
print("⚠️ Could not determine API URL from git remote")
return False
# Check if PR already exists
existing_pr = self._check_existing_pr(branch_name)
if existing_pr:
print(f"✅ Pull Request already exists: {existing_pr}")
return True
# Get current branch name (should match branch_name, but verify)
branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if branch_result.returncode != 0:
print("⚠️ Could not determine current branch")
return False
current_branch = branch_result.stdout.strip()
# Use the provided branch_name, not current_branch (they should match)
if current_branch != branch_name:
print(f"⚠️ Branch mismatch: current={current_branch}, expected={branch_name}")
# Use current_branch for PR creation
branch_name = current_branch
# PR title and body
pr_title = f"feat({self.config.component}): {task.title}"
pr_body = f"""## Task: {task.id}
**Priority:** {task.priority}
### Description
{task.description}
### Changes
This PR contains changes for task {task.id} completed by AI Agent.
### Acceptance Criteria
"""
for i, criterion in enumerate(task.acceptance_criteria, 1):
pr_body += f"{i}. {criterion}\n"
pr_body += f"""
---
*Created automatically by AI Agent*
*Component: {self.config.component}*
"""
# Create PR via API
pr_url = f"{api_url}/repos/{owner}/{repo}/pulls"
data = {
"title": pr_title,
"body": pr_body,
"head": current_branch,
"base": "master"
}
try:
req = urllib.request.Request(
pr_url,
data=json.dumps(data).encode('utf-8'),
headers={
"Authorization": f"token {self.config.forgejo_token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
)
with urllib.request.urlopen(req) as response:
if response.status in [200, 201]:
result = json.loads(response.read().decode('utf-8'))
pr_number = result.get('number', 'unknown')
pr_html_url = result.get('html_url', pr_url)
print(f"✅ Created Pull Request #{pr_number}")
print(f" URL: {pr_html_url}")
return True
else:
error_body = response.read().decode('utf-8')
print(f"❌ Failed to create PR: HTTP {response.status}")
print(f" Response: {error_body}")
return False
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
print(f"❌ Failed to create PR: HTTP {e.code}")
print(f" Response: {error_body}")
return False
except Exception as e:
print(f"❌ Failed to create PR: {e}")
return False
2025-11-21 11:50:00 +00:00
def _create_branch_and_pr(self, task: Task) -> bool:
2025-11-21 11:13:03 +00:00
"""Create a feature branch and PR for the task."""
# Get current task if not provided
if not task:
state = self.task_manager.load_state()
if not state.current_task_id:
print("⚠️ No current task ID, cannot create PR")
return False
tasks = self.task_manager.load_tasks()
task = next((t for t in tasks if t.id == state.current_task_id), None)
if not task:
print("⚠️ Current task not found, cannot create PR")
return False
# Create branch name from task ID
branch_name = f"agent/{self.config.component}/{task.id}"
branch_name = re.sub(r'[^a-zA-Z0-9/-]', '-', branch_name)
# Get current branch name
branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
# If we're already on the target branch, just push it
if current_branch == branch_name:
print(f"✅ Already on branch: {branch_name}")
else:
# Check if branch already exists locally
check_branch = subprocess.run(
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if check_branch.returncode == 0:
# Branch exists locally, just checkout
print(f"⚠️ Branch {branch_name} already exists locally, checking out...")
branch_result = subprocess.run(
["git", "checkout", branch_name],
cwd=self.config.project_root,
capture_output=True,
text=True
)
else:
# Create and checkout new branch
branch_result = subprocess.run(
["git", "checkout", "-b", branch_name],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if branch_result.returncode != 0:
print(f"⚠️ Failed to create/checkout branch: {branch_result.stderr}")
return False
print(f"✅ Created/checked out branch: {branch_name}")
# Push branch to remote
push_branch_result = subprocess.run(
["git", "push", "-u", "origin", branch_name],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if push_branch_result.returncode != 0:
print(f"⚠️ Failed to push branch: {push_branch_result.stderr}")
return False
print(f"✅ Pushed branch to remote")
# Create PR
if self._create_pull_request(task, branch_name):
print("✅ Successfully created PR")
return True
else:
print("⚠️ Failed to create PR, but branch was pushed")
return False
2025-11-20 21:28:55 +00:00
def _git_push(self) -> bool:
2025-11-21 11:13:03 +00:00
"""Push changes to remote. Behavior depends on commit_mode:
- 'pr': Always create PR (don't try to push to master)
- 'master': Try to push to master, create PR as fallback if push fails
"""
# Get current branch name
branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
# If commit_mode is 'pr', always create PR (don't try to push to master)
if self.config.commit_mode == "pr":
print("\n📝 Commit mode is 'pr', creating PR instead of pushing to master...")
# Get current task
state = self.task_manager.load_state()
if not state.current_task_id:
print("⚠️ No current task ID, cannot create PR")
return False
tasks = self.task_manager.load_tasks()
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
if not current_task:
print("⚠️ Current task not found, cannot create PR")
return False
# If we're on master, create branch and PR
if current_branch == "master":
return self._create_branch_and_pr(current_task)
else:
# We're already on a feature branch, just push it and create PR
push_branch_result = subprocess.run(
["git", "push", "-u", "origin", current_branch],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if push_branch_result.returncode == 0:
print(f"✅ Pushed branch to remote")
if self._create_pull_request(current_task, current_branch):
return True
else:
print("⚠️ Failed to create PR, but branch was pushed")
return True # Still consider it success
else:
print(f"⚠️ Push failed: {push_branch_result.stderr}")
return False
# commit_mode is 'master' - try to push to master, fallback to PR if fails
2025-11-21 07:56:05 +00:00
# First, fetch to check if there are remote changes
fetch_result = subprocess.run(
["git", "fetch"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if fetch_result.returncode != 0:
print(f"⚠️ Failed to fetch: {fetch_result.stderr}")
# Continue anyway, might be network issue
# Check if local branch is behind remote
check_result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/master"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if check_result.returncode == 0:
behind_count = check_result.stdout.strip()
if behind_count and int(behind_count) > 0:
print(f"⚠️ Local branch is {behind_count} commit(s) behind remote")
print(" Pulling latest changes before push...")
if not self._git_pull():
print("❌ Failed to pull, push may fail")
2025-11-21 11:13:03 +00:00
# If we're on master, try to push directly
if current_branch == "master":
# Now try to push
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:
# If push failed due to remote changes, try pull and push again
if "fetch first" in result.stderr or "Updates were rejected" in result.stderr:
print("⚠️ Push rejected due to remote changes")
print(" Pulling and retrying push...")
if self._git_pull():
# Retry push after pull
retry_result = subprocess.run(
["git", "push"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if retry_result.returncode == 0:
print("✅ Pushed to remote after pull")
return True
else:
print(f"❌ Push failed after pull: {retry_result.stderr}")
# Fall through to create PR (fallback)
2025-11-21 07:56:05 +00:00
else:
2025-11-21 11:13:03 +00:00
print("❌ Failed to pull before retry")
# Fall through to create PR (fallback)
# Fallback: create branch and PR
print("\n📝 Push to master failed, creating branch and PR as fallback...")
# Get current task
state = self.task_manager.load_state()
if not state.current_task_id:
print("⚠️ No current task ID, cannot create PR")
return False
tasks = self.task_manager.load_tasks()
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
if not current_task:
print("⚠️ Current task not found, cannot create PR")
2025-11-21 07:56:05 +00:00
return False
2025-11-21 11:13:03 +00:00
return self._create_branch_and_pr(current_task)
else:
# We're already on a feature branch, just push it
print(f"\n📝 Pushing to feature branch: {current_branch}")
push_branch_result = subprocess.run(
["git", "push", "-u", "origin", current_branch],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if push_branch_result.returncode == 0:
print(f"✅ Pushed branch to remote")
# Try to create PR if we have task info
state = self.task_manager.load_state()
if state.current_task_id:
tasks = self.task_manager.load_tasks()
current_task = next((t for t in tasks if t.id == state.current_task_id), None)
if current_task:
self._create_pull_request(current_task, current_branch)
return True
2025-11-21 07:56:05 +00:00
else:
2025-11-21 11:13:03 +00:00
print(f"⚠️ Push failed: {push_branch_result.stderr}")
2025-11-21 07:56:05 +00:00
return False
2025-11-20 21:28:55 +00:00
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()