first commit
This commit is contained in:
180
app_factory/agents/task_agent.py
Normal file
180
app_factory/agents/task_agent.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Task Master Agent - Bridge to claude-task-master for task graph management."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskMasterAgent:
|
||||
"""Bridge to claude-task-master for task graph management and dependency resolution."""
|
||||
|
||||
def __init__(self, project_root: str, mcp_client=None):
|
||||
self.project_root = str(project_root)
|
||||
self.mcp_client = mcp_client
|
||||
self.max_retries = 3
|
||||
self.base_delay = 1.0
|
||||
|
||||
async def parse_prd(self, prd_content: str, num_tasks: int = 10) -> dict:
|
||||
"""Write PRD content to disk and invoke task-master parse-prd."""
|
||||
docs_dir = Path(self.project_root) / ".taskmaster" / "docs"
|
||||
docs_dir.mkdir(parents=True, exist_ok=True)
|
||||
prd_path = docs_dir / "prd.md"
|
||||
prd_path.write_text(prd_content)
|
||||
|
||||
result = await self._call_with_retry(
|
||||
self._run_cli,
|
||||
"parse-prd",
|
||||
str(prd_path),
|
||||
"--num-tasks",
|
||||
str(num_tasks),
|
||||
"--force",
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_unblocked_tasks(self) -> list:
|
||||
"""Get all pending tasks whose dependencies are all done."""
|
||||
result = await self._call_with_retry(self._run_cli, "list", "--json")
|
||||
tasks = result.get("tasks", [])
|
||||
|
||||
done_ids = {
|
||||
str(t["id"]) for t in tasks if t.get("status") == "done"
|
||||
}
|
||||
|
||||
unblocked = []
|
||||
for task in tasks:
|
||||
if task.get("status") != "pending":
|
||||
continue
|
||||
deps = [str(d) for d in task.get("dependencies", [])]
|
||||
if all(d in done_ids for d in deps):
|
||||
unblocked.append(task)
|
||||
|
||||
return unblocked
|
||||
|
||||
async def update_task_status(
|
||||
self, task_id: str, status: str, notes: str = ""
|
||||
):
|
||||
"""Update a task's status and optionally add implementation notes."""
|
||||
await self._call_with_retry(
|
||||
self._run_cli,
|
||||
"set-status",
|
||||
f"--id={task_id}",
|
||||
f"--status={status}",
|
||||
)
|
||||
if notes:
|
||||
await self._call_with_retry(
|
||||
self._run_cli,
|
||||
"update-subtask",
|
||||
f"--id={task_id}",
|
||||
f"--prompt={notes}",
|
||||
)
|
||||
|
||||
async def get_task_details(self, task_id: str) -> dict:
|
||||
"""Get full details for a specific task."""
|
||||
result = await self._call_with_retry(
|
||||
self._run_cli, "show", str(task_id), "--json"
|
||||
)
|
||||
task = result.get("task", result)
|
||||
return {
|
||||
"id": task.get("id"),
|
||||
"title": task.get("title", ""),
|
||||
"description": task.get("description", ""),
|
||||
"details": task.get("details", ""),
|
||||
"testStrategy": task.get("testStrategy", ""),
|
||||
"dependencies": task.get("dependencies", []),
|
||||
"subtasks": task.get("subtasks", []),
|
||||
"status": task.get("status", "pending"),
|
||||
"priority": task.get("priority", ""),
|
||||
}
|
||||
|
||||
async def get_next_task(self) -> dict | None:
|
||||
"""Get the highest-priority unblocked task, or None."""
|
||||
try:
|
||||
result = await self._call_with_retry(
|
||||
self._run_cli, "next", "--json"
|
||||
)
|
||||
task = result.get("task", result)
|
||||
if task and task.get("id"):
|
||||
return task
|
||||
except RuntimeError:
|
||||
logger.debug("next_task command failed, falling back to manual selection")
|
||||
|
||||
unblocked = await self.get_unblocked_tasks()
|
||||
if not unblocked:
|
||||
return None
|
||||
|
||||
priority_order = {"high": 0, "medium": 1, "low": 2}
|
||||
unblocked.sort(
|
||||
key=lambda t: (
|
||||
priority_order.get(t.get("priority", "medium"), 1),
|
||||
t.get("id", 0),
|
||||
)
|
||||
)
|
||||
return unblocked[0]
|
||||
|
||||
async def expand_task(self, task_id: str, num_subtasks: int = 5) -> dict:
|
||||
"""Break a task into subtasks."""
|
||||
result = await self._call_with_retry(
|
||||
self._run_cli,
|
||||
"expand",
|
||||
f"--id={task_id}",
|
||||
f"--num={num_subtasks}",
|
||||
"--force",
|
||||
)
|
||||
return result
|
||||
|
||||
async def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Retry with exponential backoff."""
|
||||
last_exc = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < self.max_retries - 1:
|
||||
delay = self.base_delay * (2 ** attempt)
|
||||
logger.warning(
|
||||
"Attempt %d/%d failed: %s. Retrying in %.1fs",
|
||||
attempt + 1,
|
||||
self.max_retries,
|
||||
exc,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError(
|
||||
f"All {self.max_retries} attempts failed. Last error: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
async def _run_cli(self, *args: str) -> dict:
|
||||
"""Execute a task-master CLI command and return parsed JSON output."""
|
||||
cmd = ["task-master", *args]
|
||||
logger.debug("Running CLI: %s", " ".join(cmd))
|
||||
|
||||
proc = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.project_root,
|
||||
timeout=120,
|
||||
),
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"task-master {args[0]} failed (rc={proc.returncode}): {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
stdout = proc.stdout.strip()
|
||||
if not stdout:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_output": stdout}
|
||||
Reference in New Issue
Block a user