forked from Rockachopa/Timmy-time-dashboard
918 lines
28 KiB
Python
918 lines
28 KiB
Python
"""Agent dispatcher — route tasks to Claude Code, Kimi, APIs, or Timmy itself.
|
|
|
|
Timmy's dispatch system: knows what agents are available, what they're good
|
|
at, and how to send them work. Uses Gitea labels and issue comments to assign
|
|
tasks and track completion.
|
|
|
|
Dispatch flow:
|
|
1. Match task type to agent strengths
|
|
2. Check agent availability (idle or working?)
|
|
3. Dispatch task with full context (issue link, requirements, criteria)
|
|
4. Log assignment as a Gitea comment
|
|
5. Monitor for completion or timeout
|
|
6. Review output quality
|
|
7. If output fails QA → reassign or escalate
|
|
|
|
Agent interfaces:
|
|
- Claude Code → ``claude-ready`` Gitea label + issue comment
|
|
- Kimi Code → ``kimi-ready`` Gitea label + issue comment
|
|
- Agent APIs → HTTP POST to external endpoint
|
|
- Timmy (self) → direct local invocation
|
|
|
|
Usage::
|
|
|
|
from timmy.dispatcher import dispatch_task, TaskType, AgentType
|
|
|
|
result = await dispatch_task(
|
|
issue_number=1072,
|
|
task_type=TaskType.ARCHITECTURE,
|
|
title="Design the LLM router",
|
|
description="We need a cascade router...",
|
|
acceptance_criteria=["Failover works", "Metrics exposed"],
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enumerations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AgentType(StrEnum):
|
|
"""Known agents in the swarm."""
|
|
|
|
CLAUDE_CODE = "claude_code"
|
|
KIMI_CODE = "kimi_code"
|
|
AGENT_API = "agent_api"
|
|
TIMMY = "timmy"
|
|
|
|
|
|
class TaskType(StrEnum):
|
|
"""Categories of engineering work."""
|
|
|
|
# Claude Code strengths
|
|
ARCHITECTURE = "architecture"
|
|
REFACTORING = "refactoring"
|
|
COMPLEX_REASONING = "complex_reasoning"
|
|
CODE_REVIEW = "code_review"
|
|
|
|
# Kimi Code strengths
|
|
PARALLEL_IMPLEMENTATION = "parallel_implementation"
|
|
ROUTINE_CODING = "routine_coding"
|
|
FAST_ITERATION = "fast_iteration"
|
|
|
|
# Agent API strengths
|
|
RESEARCH = "research"
|
|
ANALYSIS = "analysis"
|
|
SPECIALIZED = "specialized"
|
|
|
|
# Timmy strengths
|
|
TRIAGE = "triage"
|
|
PLANNING = "planning"
|
|
CREATIVE = "creative"
|
|
ORCHESTRATION = "orchestration"
|
|
|
|
|
|
class DispatchStatus(StrEnum):
|
|
"""Lifecycle state of a dispatched task."""
|
|
|
|
PENDING = "pending"
|
|
ASSIGNED = "assigned"
|
|
IN_PROGRESS = "in_progress"
|
|
COMPLETED = "completed"
|
|
FAILED = "failed"
|
|
ESCALATED = "escalated"
|
|
TIMED_OUT = "timed_out"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class AgentSpec:
|
|
"""Capabilities and limits for a single agent."""
|
|
|
|
name: AgentType
|
|
display_name: str
|
|
strengths: frozenset[TaskType]
|
|
gitea_label: str | None # label to apply when dispatching
|
|
max_concurrent: int = 1
|
|
interface: str = "gitea" # "gitea" | "api" | "local"
|
|
api_endpoint: str | None = None # for interface="api"
|
|
|
|
|
|
#: Authoritative agent registry — all known agents and their capabilities.
|
|
AGENT_REGISTRY: dict[AgentType, AgentSpec] = {
|
|
AgentType.CLAUDE_CODE: AgentSpec(
|
|
name=AgentType.CLAUDE_CODE,
|
|
display_name="Claude Code",
|
|
strengths=frozenset(
|
|
{
|
|
TaskType.ARCHITECTURE,
|
|
TaskType.REFACTORING,
|
|
TaskType.COMPLEX_REASONING,
|
|
TaskType.CODE_REVIEW,
|
|
}
|
|
),
|
|
gitea_label="claude-ready",
|
|
max_concurrent=1,
|
|
interface="gitea",
|
|
),
|
|
AgentType.KIMI_CODE: AgentSpec(
|
|
name=AgentType.KIMI_CODE,
|
|
display_name="Kimi Code",
|
|
strengths=frozenset(
|
|
{
|
|
TaskType.PARALLEL_IMPLEMENTATION,
|
|
TaskType.ROUTINE_CODING,
|
|
TaskType.FAST_ITERATION,
|
|
}
|
|
),
|
|
gitea_label="kimi-ready",
|
|
max_concurrent=1,
|
|
interface="gitea",
|
|
),
|
|
AgentType.AGENT_API: AgentSpec(
|
|
name=AgentType.AGENT_API,
|
|
display_name="Agent API",
|
|
strengths=frozenset(
|
|
{
|
|
TaskType.RESEARCH,
|
|
TaskType.ANALYSIS,
|
|
TaskType.SPECIALIZED,
|
|
}
|
|
),
|
|
gitea_label=None,
|
|
max_concurrent=5,
|
|
interface="api",
|
|
),
|
|
AgentType.TIMMY: AgentSpec(
|
|
name=AgentType.TIMMY,
|
|
display_name="Timmy",
|
|
strengths=frozenset(
|
|
{
|
|
TaskType.TRIAGE,
|
|
TaskType.PLANNING,
|
|
TaskType.CREATIVE,
|
|
TaskType.ORCHESTRATION,
|
|
}
|
|
),
|
|
gitea_label=None,
|
|
max_concurrent=1,
|
|
interface="local",
|
|
),
|
|
}
|
|
|
|
#: Map from task type to preferred agent (primary routing table).
|
|
_TASK_ROUTING: dict[TaskType, AgentType] = {
|
|
TaskType.ARCHITECTURE: AgentType.CLAUDE_CODE,
|
|
TaskType.REFACTORING: AgentType.CLAUDE_CODE,
|
|
TaskType.COMPLEX_REASONING: AgentType.CLAUDE_CODE,
|
|
TaskType.CODE_REVIEW: AgentType.CLAUDE_CODE,
|
|
TaskType.PARALLEL_IMPLEMENTATION: AgentType.KIMI_CODE,
|
|
TaskType.ROUTINE_CODING: AgentType.KIMI_CODE,
|
|
TaskType.FAST_ITERATION: AgentType.KIMI_CODE,
|
|
TaskType.RESEARCH: AgentType.AGENT_API,
|
|
TaskType.ANALYSIS: AgentType.AGENT_API,
|
|
TaskType.SPECIALIZED: AgentType.AGENT_API,
|
|
TaskType.TRIAGE: AgentType.TIMMY,
|
|
TaskType.PLANNING: AgentType.TIMMY,
|
|
TaskType.CREATIVE: AgentType.TIMMY,
|
|
TaskType.ORCHESTRATION: AgentType.TIMMY,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routing logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def select_agent(task_type: TaskType) -> AgentType:
|
|
"""Return the best agent for *task_type* based on the routing table.
|
|
|
|
Args:
|
|
task_type: The category of engineering work to be done.
|
|
|
|
Returns:
|
|
The :class:`AgentType` best suited to handle this task.
|
|
"""
|
|
return _TASK_ROUTING.get(task_type, AgentType.TIMMY)
|
|
|
|
|
|
def infer_task_type(title: str, description: str = "") -> TaskType:
|
|
"""Heuristic: guess the most appropriate :class:`TaskType` from text.
|
|
|
|
Scans *title* and *description* for keyword signals and returns the
|
|
strongest match. Falls back to :attr:`TaskType.ROUTINE_CODING`.
|
|
|
|
Args:
|
|
title: Short task title.
|
|
description: Longer task description (optional).
|
|
|
|
Returns:
|
|
The inferred :class:`TaskType`.
|
|
"""
|
|
text = (title + " " + description).lower()
|
|
|
|
_SIGNALS: list[tuple[TaskType, frozenset[str]]] = [
|
|
(
|
|
TaskType.ARCHITECTURE,
|
|
frozenset({"architect", "design", "adr", "system design", "schema"}),
|
|
),
|
|
(
|
|
TaskType.REFACTORING,
|
|
frozenset({"refactor", "clean up", "cleanup", "reorganise", "reorganize"}),
|
|
),
|
|
(TaskType.CODE_REVIEW, frozenset({"review", "pr review", "pull request review", "audit"})),
|
|
(
|
|
TaskType.COMPLEX_REASONING,
|
|
frozenset({"complex", "hard problem", "debug", "investigate", "diagnose"}),
|
|
),
|
|
(
|
|
TaskType.RESEARCH,
|
|
frozenset({"research", "survey", "literature", "benchmark", "analyse", "analyze"}),
|
|
),
|
|
(TaskType.ANALYSIS, frozenset({"analysis", "profil", "trace", "metric", "performance"})),
|
|
(TaskType.TRIAGE, frozenset({"triage", "classify", "prioritise", "prioritize"})),
|
|
(TaskType.PLANNING, frozenset({"plan", "roadmap", "milestone", "epic", "spike"})),
|
|
(TaskType.CREATIVE, frozenset({"creative", "persona", "story", "write", "draft"})),
|
|
(TaskType.ORCHESTRATION, frozenset({"orchestrat", "coordinat", "swarm", "dispatch"})),
|
|
(TaskType.PARALLEL_IMPLEMENTATION, frozenset({"parallel", "concurrent", "batch"})),
|
|
(TaskType.FAST_ITERATION, frozenset({"quick", "fast", "iterate", "prototype", "poc"})),
|
|
]
|
|
|
|
for task_type, keywords in _SIGNALS:
|
|
if any(kw in text for kw in keywords):
|
|
return task_type
|
|
|
|
return TaskType.ROUTINE_CODING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gitea helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _post_gitea_comment(
|
|
client: Any,
|
|
base_url: str,
|
|
repo: str,
|
|
headers: dict[str, str],
|
|
issue_number: int,
|
|
body: str,
|
|
) -> int | None:
|
|
"""Post a comment on a Gitea issue and return the comment ID."""
|
|
try:
|
|
resp = await client.post(
|
|
f"{base_url}/repos/{repo}/issues/{issue_number}/comments",
|
|
headers=headers,
|
|
json={"body": body},
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
return resp.json().get("id")
|
|
logger.warning(
|
|
"Comment on #%s returned %s: %s",
|
|
issue_number,
|
|
resp.status_code,
|
|
resp.text[:200],
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Failed to post comment on #%s: %s", issue_number, exc)
|
|
return None
|
|
|
|
|
|
async def _apply_gitea_label(
|
|
client: Any,
|
|
base_url: str,
|
|
repo: str,
|
|
headers: dict[str, str],
|
|
issue_number: int,
|
|
label_name: str,
|
|
label_color: str = "#0075ca",
|
|
) -> bool:
|
|
"""Ensure *label_name* exists and apply it to an issue.
|
|
|
|
Returns True if the label was successfully applied.
|
|
"""
|
|
# Resolve or create the label
|
|
label_id: int | None = None
|
|
try:
|
|
resp = await client.get(f"{base_url}/repos/{repo}/labels", headers=headers)
|
|
if resp.status_code == 200:
|
|
for lbl in resp.json():
|
|
if lbl.get("name") == label_name:
|
|
label_id = lbl["id"]
|
|
break
|
|
except Exception as exc:
|
|
logger.warning("Failed to list labels: %s", exc)
|
|
return False
|
|
|
|
if label_id is None:
|
|
try:
|
|
resp = await client.post(
|
|
f"{base_url}/repos/{repo}/labels",
|
|
headers=headers,
|
|
json={"name": label_name, "color": label_color},
|
|
)
|
|
if resp.status_code in (200, 201):
|
|
label_id = resp.json().get("id")
|
|
except Exception as exc:
|
|
logger.warning("Failed to create label %r: %s", label_name, exc)
|
|
return False
|
|
|
|
if label_id is None:
|
|
return False
|
|
|
|
# Apply label to the issue
|
|
try:
|
|
resp = await client.post(
|
|
f"{base_url}/repos/{repo}/issues/{issue_number}/labels",
|
|
headers=headers,
|
|
json={"labels": [label_id]},
|
|
)
|
|
return resp.status_code in (200, 201)
|
|
except Exception as exc:
|
|
logger.warning("Failed to apply label %r to #%s: %s", label_name, issue_number, exc)
|
|
return False
|
|
|
|
|
|
async def _poll_issue_completion(
|
|
issue_number: int,
|
|
poll_interval: int = 60,
|
|
max_wait: int = 7200,
|
|
) -> DispatchStatus:
|
|
"""Poll a Gitea issue until closed (completed) or timeout.
|
|
|
|
Args:
|
|
issue_number: Gitea issue to watch.
|
|
poll_interval: Seconds between polls.
|
|
max_wait: Maximum total seconds to wait.
|
|
|
|
Returns:
|
|
:attr:`DispatchStatus.COMPLETED` if the issue was closed,
|
|
:attr:`DispatchStatus.TIMED_OUT` otherwise.
|
|
"""
|
|
try:
|
|
import httpx
|
|
except ImportError as exc:
|
|
logger.warning("poll_issue_completion: missing dependency: %s", exc)
|
|
return DispatchStatus.FAILED
|
|
|
|
base_url = f"{settings.gitea_url}/api/v1"
|
|
repo = settings.gitea_repo
|
|
headers = {"Authorization": f"token {settings.gitea_token}"}
|
|
issue_url = f"{base_url}/repos/{repo}/issues/{issue_number}"
|
|
|
|
elapsed = 0
|
|
while elapsed < max_wait:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(issue_url, headers=headers)
|
|
if resp.status_code == 200 and resp.json().get("state") == "closed":
|
|
logger.info("Issue #%s closed — task completed", issue_number)
|
|
return DispatchStatus.COMPLETED
|
|
except Exception as exc:
|
|
logger.warning("Poll error for issue #%s: %s", issue_number, exc)
|
|
|
|
await asyncio.sleep(poll_interval)
|
|
elapsed += poll_interval
|
|
|
|
logger.warning("Timed out waiting for issue #%s after %ss", issue_number, max_wait)
|
|
return DispatchStatus.TIMED_OUT
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
async def _log_escalation(
|
|
issue_number: int,
|
|
agent: AgentType,
|
|
error: str,
|
|
) -> None:
|
|
"""Post an escalation notice on the Gitea issue."""
|
|
try:
|
|
import httpx
|
|
|
|
if not settings.gitea_enabled or not settings.gitea_token:
|
|
return
|
|
|
|
base_url = f"{settings.gitea_url}/api/v1"
|
|
repo = settings.gitea_repo
|
|
headers = {
|
|
"Authorization": f"token {settings.gitea_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
body = (
|
|
f"## Dispatch Escalated\n\n"
|
|
f"Could not assign to **{AGENT_REGISTRY[agent].display_name}** "
|
|
f"after {1} attempt(s).\n\n"
|
|
f"**Error:** {error}\n\n"
|
|
f"Manual intervention required.\n\n"
|
|
f"---\n*Timmy agent dispatcher.*"
|
|
)
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
await _post_gitea_comment(client, base_url, repo, headers, issue_number, body)
|
|
except Exception as exc:
|
|
logger.warning("Failed to post escalation comment: %s", exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Monitoring helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def wait_for_completion(
|
|
issue_number: int,
|
|
poll_interval: int = 60,
|
|
max_wait: int = 7200,
|
|
) -> DispatchStatus:
|
|
"""Block until the assigned Gitea issue is closed or the timeout fires.
|
|
|
|
Useful for synchronous orchestration where the caller wants to wait for
|
|
the assigned agent to finish before proceeding.
|
|
|
|
Args:
|
|
issue_number: Gitea issue to monitor.
|
|
poll_interval: Seconds between status polls.
|
|
max_wait: Maximum wait in seconds (default 2 hours).
|
|
|
|
Returns:
|
|
:attr:`DispatchStatus.COMPLETED` or :attr:`DispatchStatus.TIMED_OUT`.
|
|
"""
|
|
return await _poll_issue_completion(issue_number, poll_interval, max_wait)
|