206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""Dev Agent Manager - Spawns Dev Agents in Docker containers via Claude Code."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pexpect
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROMPT_TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "prompts" / "dev_task_execution.txt"
|
|
|
|
|
|
class DevAgentManager:
|
|
"""Spawns Dev Agents in Docker containers, interfaces with Claude Code via pexpect."""
|
|
|
|
def __init__(self, docker_client=None, max_retries: int = 3, timeout: int = 1800):
|
|
"""Initialize DevAgentManager.
|
|
|
|
Args:
|
|
docker_client: Docker client instance (or None to create from env).
|
|
max_retries: Maximum Dev-QA bounce retries per task.
|
|
timeout: Timeout in seconds for Claude Code execution (default 30 min).
|
|
"""
|
|
if docker_client is not None:
|
|
self.docker_client = docker_client
|
|
else:
|
|
import docker
|
|
self.docker_client = docker.from_env()
|
|
|
|
self.max_retries = max_retries
|
|
self.timeout = timeout
|
|
self._retry_counts: dict[str, int] = {}
|
|
|
|
def prepare_task_prompt(self, task: dict, global_arch: str = "") -> str:
|
|
"""Build a prompt string for the Dev Agent from the template.
|
|
|
|
Args:
|
|
task: Task dict with keys task_id, title, description, details, testStrategy.
|
|
global_arch: Optional global architecture summary.
|
|
|
|
Returns:
|
|
Formatted prompt string.
|
|
"""
|
|
template = PROMPT_TEMPLATE_PATH.read_text()
|
|
return template.format(
|
|
task_id=task.get("task_id", task.get("id", "")),
|
|
title=task.get("title", ""),
|
|
description=task.get("description", ""),
|
|
details=task.get("details", ""),
|
|
test_strategy=task.get("testStrategy", ""),
|
|
global_architecture=global_arch or "No architecture context provided.",
|
|
)
|
|
|
|
async def execute_task(
|
|
self,
|
|
task: dict,
|
|
container_id: str,
|
|
worktree_path: str,
|
|
global_arch: str = "",
|
|
) -> dict:
|
|
"""Execute a task inside a Docker container using Claude Code.
|
|
|
|
Args:
|
|
task: Task dict.
|
|
container_id: Docker container ID to exec into.
|
|
worktree_path: Host path to the worktree (mounted at /workspace).
|
|
global_arch: Optional architecture context.
|
|
|
|
Returns:
|
|
Dict with status, output, files_changed, and exit_code.
|
|
"""
|
|
prompt = self.prepare_task_prompt(task, global_arch)
|
|
|
|
# Write prompt to temp file in worktree so it's visible inside the container
|
|
prompt_file = os.path.join(worktree_path, ".task_prompt.txt")
|
|
with open(prompt_file, "w") as f:
|
|
f.write(prompt)
|
|
|
|
cmd = f"docker exec {container_id} claude --print --prompt-file /workspace/.task_prompt.txt"
|
|
|
|
try:
|
|
child = pexpect.spawn(cmd, timeout=self.timeout, encoding="utf-8")
|
|
child.expect(pexpect.EOF, timeout=self.timeout)
|
|
output = child.before or ""
|
|
child.close()
|
|
exit_code = child.exitstatus if child.exitstatus is not None else -1
|
|
except pexpect.TIMEOUT:
|
|
try:
|
|
child.close(force=True)
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"status": "failed",
|
|
"output": "timeout",
|
|
"files_changed": [],
|
|
"exit_code": -1,
|
|
}
|
|
finally:
|
|
# Clean up prompt file
|
|
try:
|
|
os.remove(prompt_file)
|
|
except OSError:
|
|
pass
|
|
|
|
parsed = self.parse_claude_output(output)
|
|
|
|
if exit_code == 0:
|
|
status = "success"
|
|
else:
|
|
status = "failed"
|
|
|
|
return {
|
|
"status": status,
|
|
"output": output,
|
|
"files_changed": parsed["files_changed"],
|
|
"exit_code": exit_code,
|
|
}
|
|
|
|
def parse_claude_output(self, output: str) -> dict:
|
|
"""Parse Claude Code output to extract structured info.
|
|
|
|
Args:
|
|
output: Raw stdout from Claude Code.
|
|
|
|
Returns:
|
|
Dict with files_changed, test_results, and errors.
|
|
"""
|
|
# Extract file paths (common patterns: Created/Modified/Updated path/to/file.py)
|
|
file_patterns = re.findall(
|
|
r"(?:(?:Creat|Modifi|Updat|Edit|Writ)(?:ed|ing)\s+)([^\s]+\.\w+)",
|
|
output,
|
|
)
|
|
# Also catch paths that look like source files mentioned standalone
|
|
standalone_paths = re.findall(
|
|
r"(?:^|\s)([\w./]+\.(?:py|js|ts|yaml|yml|json|txt|md|toml|cfg))\b",
|
|
output,
|
|
)
|
|
all_files = list(dict.fromkeys(file_patterns + standalone_paths)) # dedupe, preserve order
|
|
|
|
# Extract test results
|
|
test_results = {}
|
|
passed_match = re.search(r"(\d+)\s+passed", output)
|
|
failed_match = re.search(r"(\d+)\s+failed", output)
|
|
if passed_match:
|
|
test_results["passed"] = int(passed_match.group(1))
|
|
if failed_match:
|
|
test_results["failed"] = int(failed_match.group(1))
|
|
|
|
# Extract error messages
|
|
errors = re.findall(r"(?:Error|Exception|FAILED)[:\s]+(.*?)(?:\n|$)", output, re.IGNORECASE)
|
|
|
|
return {
|
|
"files_changed": all_files,
|
|
"test_results": test_results,
|
|
"errors": errors,
|
|
}
|
|
|
|
async def execute_with_retry(
|
|
self,
|
|
task: dict,
|
|
container_id: str,
|
|
worktree_path: str,
|
|
global_arch: str = "",
|
|
) -> dict:
|
|
"""Execute a task with retry logic.
|
|
|
|
Retries up to max_retries times on failure. If all retries are exhausted,
|
|
returns a result with status 'needs_clarification'.
|
|
|
|
Args:
|
|
task: Task dict.
|
|
container_id: Docker container ID.
|
|
worktree_path: Host worktree path.
|
|
global_arch: Optional architecture context.
|
|
|
|
Returns:
|
|
Final execution result dict.
|
|
"""
|
|
task_id = str(task.get("task_id", task.get("id", "")))
|
|
|
|
for attempt in range(self.max_retries):
|
|
self._retry_counts[task_id] = attempt + 1
|
|
result = await self.execute_task(task, container_id, worktree_path, global_arch)
|
|
if result["status"] == "success":
|
|
return result
|
|
|
|
# All retries exhausted
|
|
return {
|
|
"status": "needs_clarification",
|
|
"output": result.get("output", ""),
|
|
"files_changed": result.get("files_changed", []),
|
|
"exit_code": result.get("exit_code", -1),
|
|
}
|
|
|
|
def get_retry_count(self, task_id: str) -> int:
|
|
"""Return current retry count for a task."""
|
|
return self._retry_counts.get(task_id, 0)
|
|
|
|
def reset_retry_count(self, task_id: str):
|
|
"""Reset retry counter for a task (after clarification resolved)."""
|
|
self._retry_counts.pop(task_id, None)
|