492 lines
15 KiB
Python
492 lines
15 KiB
Python
"""Core dispatch functions — validate, format, and send tasks to agents.
|
|
|
|
Contains :func:`dispatch_task` (the primary entry point) and the
|
|
per-interface dispatch helpers (:func:`_dispatch_via_gitea`,
|
|
:func:`_dispatch_via_api`, :func:`_dispatch_local`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from config import settings
|
|
|
|
from .queue import _apply_gitea_label, _log_escalation, _post_gitea_comment
|
|
from .routing import (
|
|
AGENT_REGISTRY,
|
|
AgentType,
|
|
DispatchStatus,
|
|
TaskType,
|
|
infer_task_type,
|
|
select_agent,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class DispatchResult:
|
|
"""Outcome of a dispatch call."""
|
|
|
|
task_type: TaskType
|
|
agent: AgentType
|
|
issue_number: int | None
|
|
status: DispatchStatus
|
|
comment_id: int | None = None
|
|
label_applied: str | None = None
|
|
error: str | None = None
|
|
retry_count: int = 0
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def success(self) -> bool: # noqa: D401
|
|
return self.status in (DispatchStatus.ASSIGNED, DispatchStatus.COMPLETED)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core dispatch functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _format_assignment_comment(
|
|
display_name: str,
|
|
task_type: TaskType,
|
|
description: str,
|
|
acceptance_criteria: list[str],
|
|
) -> str:
|
|
"""Build the markdown comment body for a task assignment.
|
|
|
|
Args:
|
|
display_name: Human-readable agent name.
|
|
task_type: The inferred task type.
|
|
description: Task description.
|
|
acceptance_criteria: List of acceptance criteria strings.
|
|
|
|
Returns:
|
|
Formatted markdown string for the comment.
|
|
"""
|
|
criteria_md = (
|
|
"\n".join(f"- {c}" for c in acceptance_criteria)
|
|
if acceptance_criteria
|
|
else "_None specified_"
|
|
)
|
|
return (
|
|
f"## Assigned to {display_name}\n\n"
|
|
f"**Task type:** `{task_type.value}`\n\n"
|
|
f"**Description:**\n{description}\n\n"
|
|
f"**Acceptance criteria:**\n{criteria_md}\n\n"
|
|
f"---\n*Dispatched by Timmy agent dispatcher.*"
|
|
)
|
|
|
|
|
|
def _select_label(agent: AgentType) -> str | None:
|
|
"""Return the Gitea label for an agent based on its spec.
|
|
|
|
Args:
|
|
agent: The target agent.
|
|
|
|
Returns:
|
|
Label name or None if the agent has no label.
|
|
"""
|
|
return AGENT_REGISTRY[agent].gitea_label
|
|
|
|
|
|
async def _dispatch_via_gitea(
|
|
agent: AgentType,
|
|
issue_number: int,
|
|
title: str,
|
|
description: str,
|
|
acceptance_criteria: list[str],
|
|
) -> DispatchResult:
|
|
"""Assign a task by applying a Gitea label and posting an assignment comment.
|
|
|
|
Args:
|
|
agent: Target agent.
|
|
issue_number: Gitea issue to assign.
|
|
title: Short task title.
|
|
description: Full task description.
|
|
acceptance_criteria: List of acceptance criteria strings.
|
|
|
|
Returns:
|
|
:class:`DispatchResult` describing the outcome.
|
|
"""
|
|
try:
|
|
import httpx
|
|
except ImportError as exc:
|
|
return DispatchResult(
|
|
task_type=TaskType.ROUTINE_CODING,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error=f"Missing dependency: {exc}",
|
|
)
|
|
|
|
spec = AGENT_REGISTRY[agent]
|
|
task_type = infer_task_type(title, description)
|
|
|
|
if not settings.gitea_enabled or not settings.gitea_token:
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error="Gitea integration not configured (no token or disabled).",
|
|
)
|
|
|
|
base_url = f"{settings.gitea_url}/api/v1"
|
|
repo = settings.gitea_repo
|
|
headers = {
|
|
"Authorization": f"token {settings.gitea_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
comment_id: int | None = None
|
|
label_applied: str | None = None
|
|
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
# 1. Apply agent label (if applicable)
|
|
label = _select_label(agent)
|
|
if label:
|
|
ok = await _apply_gitea_label(client, base_url, repo, headers, issue_number, label)
|
|
if ok:
|
|
label_applied = label
|
|
logger.info(
|
|
"Applied label %r to issue #%s for %s",
|
|
label,
|
|
issue_number,
|
|
spec.display_name,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Could not apply label %r to issue #%s",
|
|
label,
|
|
issue_number,
|
|
)
|
|
|
|
# 2. Post assignment comment
|
|
comment_body = _format_assignment_comment(
|
|
spec.display_name, task_type, description, acceptance_criteria
|
|
)
|
|
comment_id = await _post_gitea_comment(
|
|
client, base_url, repo, headers, issue_number, comment_body
|
|
)
|
|
|
|
if comment_id is not None or label_applied is not None:
|
|
logger.info(
|
|
"Dispatched issue #%s to %s (label=%r, comment=%s)",
|
|
issue_number,
|
|
spec.display_name,
|
|
label_applied,
|
|
comment_id,
|
|
)
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.ASSIGNED,
|
|
comment_id=comment_id,
|
|
label_applied=label_applied,
|
|
)
|
|
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error="Failed to apply label and post comment — check Gitea connectivity.",
|
|
)
|
|
|
|
|
|
async def _dispatch_via_api(
|
|
agent: AgentType,
|
|
title: str,
|
|
description: str,
|
|
acceptance_criteria: list[str],
|
|
issue_number: int | None = None,
|
|
endpoint: str | None = None,
|
|
) -> DispatchResult:
|
|
"""Dispatch a task to an external HTTP API agent.
|
|
|
|
Args:
|
|
agent: Target agent.
|
|
title: Short task title.
|
|
description: Task description.
|
|
acceptance_criteria: List of acceptance criteria.
|
|
issue_number: Optional Gitea issue for cross-referencing.
|
|
endpoint: Override API endpoint URL (uses spec default if omitted).
|
|
|
|
Returns:
|
|
:class:`DispatchResult` describing the outcome.
|
|
"""
|
|
spec = AGENT_REGISTRY[agent]
|
|
task_type = infer_task_type(title, description)
|
|
url = endpoint or spec.api_endpoint
|
|
|
|
if not url:
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error=f"No API endpoint configured for agent {agent.value}.",
|
|
)
|
|
|
|
payload = {
|
|
"title": title,
|
|
"description": description,
|
|
"acceptance_criteria": acceptance_criteria,
|
|
"issue_number": issue_number,
|
|
"agent": agent.value,
|
|
"task_type": task_type.value,
|
|
}
|
|
|
|
try:
|
|
import httpx
|
|
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.post(url, json=payload)
|
|
|
|
if resp.status_code in (200, 201, 202):
|
|
logger.info("Dispatched %r to API agent %s at %s", title[:60], agent.value, url)
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.ASSIGNED,
|
|
metadata={"response": resp.json() if resp.content else {}},
|
|
)
|
|
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error=f"API agent returned {resp.status_code}: {resp.text[:200]}",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("API dispatch to %s failed: %s", url, exc)
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=agent,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error=str(exc),
|
|
)
|
|
|
|
|
|
async def _dispatch_local(
|
|
title: str,
|
|
description: str = "",
|
|
acceptance_criteria: list[str] | None = None,
|
|
issue_number: int | None = None,
|
|
) -> DispatchResult:
|
|
"""Handle a task locally — Timmy processes it directly.
|
|
|
|
This is a lightweight stub. Real local execution should be wired
|
|
into the agentic loop or a dedicated Timmy tool.
|
|
|
|
Args:
|
|
title: Short task title.
|
|
description: Task description.
|
|
acceptance_criteria: Acceptance criteria list.
|
|
issue_number: Optional Gitea issue number for logging.
|
|
|
|
Returns:
|
|
:class:`DispatchResult` with ASSIGNED status (local execution is
|
|
assumed to succeed at dispatch time).
|
|
"""
|
|
task_type = infer_task_type(title, description)
|
|
logger.info("Timmy handling task locally: %r (issue #%s)", title[:60], issue_number)
|
|
return DispatchResult(
|
|
task_type=task_type,
|
|
agent=AgentType.TIMMY,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.ASSIGNED,
|
|
metadata={"local": True, "description": description},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _validate_task(
|
|
title: str,
|
|
task_type: TaskType | None,
|
|
agent: AgentType | None,
|
|
issue_number: int | None,
|
|
) -> DispatchResult | None:
|
|
"""Validate task preconditions.
|
|
|
|
Args:
|
|
title: Task title to validate.
|
|
task_type: Optional task type for result construction.
|
|
agent: Optional agent for result construction.
|
|
issue_number: Optional issue number for result construction.
|
|
|
|
Returns:
|
|
A failed DispatchResult if validation fails, None otherwise.
|
|
"""
|
|
if not title.strip():
|
|
return DispatchResult(
|
|
task_type=task_type or TaskType.ROUTINE_CODING,
|
|
agent=agent or AgentType.TIMMY,
|
|
issue_number=issue_number,
|
|
status=DispatchStatus.FAILED,
|
|
error="`title` is required.",
|
|
)
|
|
return None
|
|
|
|
|
|
def _select_dispatch_strategy(agent: AgentType, issue_number: int | None) -> str:
|
|
"""Select the dispatch strategy based on agent interface and context.
|
|
|
|
Args:
|
|
agent: The target agent.
|
|
issue_number: Optional Gitea issue number.
|
|
|
|
Returns:
|
|
Strategy name: "gitea", "api", or "local".
|
|
"""
|
|
spec = AGENT_REGISTRY[agent]
|
|
if spec.interface == "gitea" and issue_number is not None:
|
|
return "gitea"
|
|
if spec.interface == "api":
|
|
return "api"
|
|
return "local"
|
|
|
|
|
|
def _log_dispatch_result(
|
|
title: str,
|
|
result: DispatchResult,
|
|
attempt: int,
|
|
max_retries: int,
|
|
) -> None:
|
|
"""Log the outcome of a dispatch attempt.
|
|
|
|
Args:
|
|
title: Task title for logging context.
|
|
result: The dispatch result.
|
|
attempt: Current attempt number (0-indexed).
|
|
max_retries: Maximum retry attempts allowed.
|
|
"""
|
|
if result.success:
|
|
return
|
|
|
|
if attempt > 0:
|
|
logger.info("Retry %d/%d for task %r", attempt, max_retries, title[:60])
|
|
|
|
logger.warning(
|
|
"Dispatch attempt %d failed for task %r: %s",
|
|
attempt + 1,
|
|
title[:60],
|
|
result.error,
|
|
)
|
|
|
|
|
|
async def dispatch_task(
|
|
title: str,
|
|
description: str = "",
|
|
acceptance_criteria: list[str] | None = None,
|
|
task_type: TaskType | None = None,
|
|
agent: AgentType | None = None,
|
|
issue_number: int | None = None,
|
|
api_endpoint: str | None = None,
|
|
max_retries: int = 1,
|
|
) -> DispatchResult:
|
|
"""Route a task to the best available agent.
|
|
|
|
This is the primary entry point. Callers can either specify the
|
|
*agent* and *task_type* explicitly or let the dispatcher infer them
|
|
from the *title* and *description*.
|
|
|
|
Args:
|
|
title: Short human-readable task title.
|
|
description: Full task description with context.
|
|
acceptance_criteria: List of acceptance criteria strings.
|
|
task_type: Override automatic task type inference.
|
|
agent: Override automatic agent selection.
|
|
issue_number: Gitea issue number to log the assignment on.
|
|
api_endpoint: Override API endpoint for AGENT_API dispatches.
|
|
max_retries: Number of retry attempts on failure (default 1).
|
|
|
|
Returns:
|
|
:class:`DispatchResult` describing the final dispatch outcome.
|
|
|
|
Example::
|
|
|
|
result = await dispatch_task(
|
|
issue_number=1072,
|
|
title="Build the cascade LLM router",
|
|
description="We need automatic failover...",
|
|
acceptance_criteria=["Circuit breaker works", "Metrics exposed"],
|
|
)
|
|
if result.success:
|
|
print(f"Assigned to {result.agent.value}")
|
|
"""
|
|
# 1. Validate
|
|
validation_error = _validate_task(title, task_type, agent, issue_number)
|
|
if validation_error:
|
|
return validation_error
|
|
|
|
# 2. Resolve task type and agent
|
|
criteria = acceptance_criteria or []
|
|
resolved_type = task_type or infer_task_type(title, description)
|
|
resolved_agent = agent or select_agent(resolved_type)
|
|
|
|
logger.info(
|
|
"Dispatching task %r → %s (type=%s, issue=#%s)",
|
|
title[:60],
|
|
resolved_agent.value,
|
|
resolved_type.value,
|
|
issue_number,
|
|
)
|
|
|
|
# 3. Select strategy and dispatch with retries
|
|
strategy = _select_dispatch_strategy(resolved_agent, issue_number)
|
|
last_result: DispatchResult | None = None
|
|
|
|
for attempt in range(max_retries + 1):
|
|
if strategy == "gitea":
|
|
result = await _dispatch_via_gitea(
|
|
resolved_agent, issue_number, title, description, criteria
|
|
)
|
|
elif strategy == "api":
|
|
result = await _dispatch_via_api(
|
|
resolved_agent, title, description, criteria, issue_number, api_endpoint
|
|
)
|
|
else:
|
|
result = await _dispatch_local(title, description, criteria, issue_number)
|
|
|
|
result.retry_count = attempt
|
|
last_result = result
|
|
|
|
if result.success:
|
|
return result
|
|
|
|
_log_dispatch_result(title, result, attempt, max_retries)
|
|
|
|
# 4. All attempts exhausted — escalate
|
|
assert last_result is not None
|
|
last_result.status = DispatchStatus.ESCALATED
|
|
logger.error(
|
|
"Task %r escalated after %d failed attempt(s): %s",
|
|
title[:60],
|
|
max_retries + 1,
|
|
last_result.error,
|
|
)
|
|
|
|
# Try to log the escalation on the issue
|
|
if issue_number is not None:
|
|
await _log_escalation(issue_number, resolved_agent, last_result.error or "unknown error")
|
|
|
|
return last_result
|