diff --git a/src/timmy/dispatcher.py b/src/timmy/dispatcher.py index 3f2a5fd1..b2f6edd9 100644 --- a/src/timmy/dispatcher.py +++ b/src/timmy/dispatcher.py @@ -686,6 +686,80 @@ async def _dispatch_local( # --------------------------------------------------------------------------- +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 = "", @@ -726,17 +800,13 @@ async def dispatch_task( 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 [] - - 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.", - ) - resolved_type = task_type or infer_task_type(title, description) resolved_agent = agent or select_agent(resolved_type) @@ -748,18 +818,16 @@ async def dispatch_task( issue_number, ) - spec = AGENT_REGISTRY[resolved_agent] - + # 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 attempt > 0: - logger.info("Retry %d/%d for task %r", attempt, max_retries, title[:60]) - if spec.interface == "gitea" and issue_number is not None: + for attempt in range(max_retries + 1): + if strategy == "gitea": result = await _dispatch_via_gitea( resolved_agent, issue_number, title, description, criteria ) - elif spec.interface == "api": + elif strategy == "api": result = await _dispatch_via_api( resolved_agent, title, description, criteria, issue_number, api_endpoint ) @@ -772,14 +840,9 @@ async def dispatch_task( if result.success: return result - logger.warning( - "Dispatch attempt %d failed for task %r: %s", - attempt + 1, - title[:60], - result.error, - ) + _log_dispatch_result(title, result, attempt, max_retries) - # All attempts exhausted — escalate + # 4. All attempts exhausted — escalate assert last_result is not None last_result.status = DispatchStatus.ESCALATED logger.error(