mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""
|
|
Task Handler Helpers
|
|
|
|
Shared validation and utility functions for task handlers.
|
|
"""
|
|
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from roboco.mcp.tasks import get_available_tasks_guidance
|
|
from roboco.mcp.utils import ApiClient, format_error_response, resolve_agent_uuid_cached
|
|
|
|
|
|
async def get_available_tasks_for_role(
|
|
client: ApiClient, agent_role: str, team: str | None
|
|
) -> list[dict[str, Any]]:
|
|
"""Get available tasks based on agent role."""
|
|
params = {"team": team} if team else {}
|
|
|
|
if agent_role == "qa":
|
|
resp = await client.get("/tasks/awaiting-qa", params=params)
|
|
return resp.json() if resp.ok else []
|
|
|
|
if agent_role == "documenter":
|
|
resp = await client.get("/tasks/awaiting-docs", params=params)
|
|
return resp.json() if resp.ok else []
|
|
|
|
if agent_role in ("cell_pm", "main_pm"):
|
|
pending_params = {**params, "status": "pending"}
|
|
pending_resp = await client.get("/tasks", params=pending_params)
|
|
pending = pending_resp.json() if pending_resp.ok else []
|
|
review_resp = await client.get(
|
|
"/tasks", params={**params, "status": "awaiting_pm_review"}
|
|
)
|
|
review = review_resp.json() if review_resp.ok else []
|
|
return pending + review
|
|
|
|
resp = await client.get("/tasks", params={**params, "status": "pending"})
|
|
return resp.json() if resp.ok else []
|
|
|
|
|
|
def _get_role_pending_task_guidance(
|
|
assigned_tasks: list[dict], agent_role: str
|
|
) -> str | None:
|
|
"""Get special guidance for non-standard pending tasks.
|
|
|
|
When QA/Documenter is directly assigned a pending task (not their usual queue),
|
|
they need to know it's direct work - use submit_pm_review workflow.
|
|
"""
|
|
pending_tasks = [t for t in assigned_tasks if t.get("status") == "pending"]
|
|
if not pending_tasks:
|
|
return None
|
|
|
|
role_hints = {
|
|
"qa": "These are NOT QA reviews",
|
|
"documenter": "These are NOT awaiting_documentation tasks",
|
|
}
|
|
hint = role_hints.get(agent_role, "These are direct assignments")
|
|
|
|
return (
|
|
f"You have {len(pending_tasks)} PENDING task(s) directly assigned to you. "
|
|
f"{hint} - they are tasks assigned for YOU to complete. "
|
|
"Workflow: claim → plan → start → work → submit_pm_review. "
|
|
"Use roboco_task_get to see details, then roboco_task_claim to start."
|
|
)
|
|
|
|
|
|
def get_scan_guidance(
|
|
paused_tasks: list[dict],
|
|
assigned_tasks: list[dict],
|
|
available_tasks: list[dict],
|
|
agent_role: str | None,
|
|
) -> str:
|
|
"""Get guidance message based on task scan results."""
|
|
if paused_tasks:
|
|
return (
|
|
f"You have {len(paused_tasks)} paused task(s). "
|
|
"Resume your paused work before claiming new tasks."
|
|
)
|
|
|
|
# Special case: QA/Documenter with pending tasks (not their usual queue)
|
|
if agent_role in ("qa", "documenter"):
|
|
pending_guidance = _get_role_pending_task_guidance(assigned_tasks, agent_role)
|
|
if pending_guidance:
|
|
return pending_guidance
|
|
|
|
if assigned_tasks:
|
|
return (
|
|
f"You have {len(assigned_tasks)} active task(s). "
|
|
"Continue working on your assigned tasks."
|
|
)
|
|
if available_tasks:
|
|
return get_available_tasks_guidance(available_tasks, agent_role or "unknown")
|
|
return (
|
|
"No tasks available. Call roboco_agent_idle() "
|
|
"to signal availability, or check back later."
|
|
)
|
|
|
|
|
|
def check_blocking_tasks(active_tasks: list[dict]) -> dict[str, Any] | None:
|
|
"""Check for blocking active tasks. Returns error or None.
|
|
|
|
NOTE: "pending" is NOT blocking. If PM assigned multiple pending tasks,
|
|
agent should be able to claim any of them. Only tasks being actively
|
|
worked on (claimed, in_progress, verifying) block new claims.
|
|
"""
|
|
blocking_statuses = ["claimed", "in_progress", "verifying"]
|
|
blocking = [t for t in active_tasks if t.get("status") in blocking_statuses]
|
|
if blocking:
|
|
status = blocking[0].get("status", "active")
|
|
return format_error_response(
|
|
"ALREADY_ACTIVE",
|
|
f"You have a {status} task: {blocking[0]['id']}. "
|
|
"Work on it first, or pause it if blocked.",
|
|
{"active_task_id": blocking[0]["id"], "status": status},
|
|
)
|
|
return None
|
|
|
|
|
|
def check_paused_tasks(active_tasks: list[dict]) -> dict[str, Any] | None:
|
|
"""Check for paused tasks. Returns error or None."""
|
|
paused = [t for t in active_tasks if t.get("status") == "paused"]
|
|
if paused:
|
|
return format_error_response(
|
|
"PAUSED_TASKS_EXIST",
|
|
f"You have {len(paused)} paused task(s). "
|
|
"Resume paused work before claiming new tasks.",
|
|
{"paused_task_ids": [t["id"] for t in paused]},
|
|
)
|
|
return None
|
|
|
|
|
|
async def validate_task_claimable(
|
|
task: dict, agent_role: str, agent_id: str, client: ApiClient
|
|
) -> dict[str, Any] | None:
|
|
"""Validate task can be claimed based on agent role.
|
|
|
|
Special case: If an agent is already assigned to a pending task (PM assigned
|
|
it directly to them), they can claim it to transition to 'claimed' status.
|
|
"""
|
|
task_status = task.get("status")
|
|
claimable_statuses = {
|
|
"qa": ["awaiting_qa"],
|
|
# Documenters: pending (direct docs tasks) or awaiting_documentation (workflow)
|
|
"documenter": ["pending", "awaiting_documentation"],
|
|
}
|
|
allowed = claimable_statuses.get(agent_role, ["pending"])
|
|
|
|
# Special case: agent can claim pending tasks already assigned to them
|
|
# This handles PM directly assigning tasks to QA/docs agents
|
|
if task_status == "pending":
|
|
assigned_to = task.get("assigned_to")
|
|
if assigned_to:
|
|
agent_uuid = await resolve_agent_uuid_cached(agent_id, client)
|
|
if agent_uuid and assigned_to == agent_uuid:
|
|
return None # Allow claiming - already assigned to this agent
|
|
|
|
if task_status not in allowed:
|
|
return format_error_response(
|
|
"INVALID_STATE",
|
|
f"Cannot claim task in '{task_status}' status. "
|
|
f"Your role ({agent_role}) can claim: {', '.join(allowed)}.",
|
|
{"current_status": task_status, "allowed_statuses": allowed},
|
|
)
|
|
return None
|
|
|
|
|
|
async def get_project_context(
|
|
client: ApiClient, project_id: str
|
|
) -> dict[str, Any] | None:
|
|
"""Fetch project context if available."""
|
|
resp = await client.get(f"/projects/{project_id}")
|
|
if resp.ok:
|
|
result: dict[str, Any] = resp.json()
|
|
return result
|
|
return None
|
|
|
|
|
|
async def fetch_task_or_error(
|
|
client: ApiClient, task_id: str
|
|
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
"""Fetch task by ID, returning (task, None) or (None, error_response)."""
|
|
from fastapi import status
|
|
|
|
resp = await client.get(f"/tasks/{task_id}")
|
|
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
|
return None, format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
|
task: dict[str, Any] = resp.json()
|
|
return task, None
|
|
|
|
|
|
def validate_task_status(
|
|
task: dict[str, Any], expected: str, action_desc: str
|
|
) -> dict[str, Any] | None:
|
|
"""Validate task is in expected status. Returns error or None."""
|
|
if task.get("status") != expected:
|
|
return format_error_response(
|
|
"INVALID_STATE",
|
|
f"Can only {action_desc} tasks in '{expected}' status. "
|
|
f"Current: '{task.get('status')}'",
|
|
)
|
|
return None
|
|
|
|
|
|
def validate_task_status_in(
|
|
task: dict[str, Any], allowed: set[str], action_desc: str
|
|
) -> dict[str, Any] | None:
|
|
"""Validate task is in one of the allowed statuses. Returns error or None.
|
|
|
|
Use this for workflow validations where multiple statuses are valid entry points.
|
|
Example: QA can pass tasks in awaiting_qa, claimed, or in_progress status.
|
|
"""
|
|
task_status = task.get("status")
|
|
if task_status not in allowed:
|
|
return format_error_response(
|
|
"INVALID_STATE",
|
|
f"Can only {action_desc} tasks in {', '.join(sorted(allowed))} status. "
|
|
f"Current: '{task_status}'",
|
|
)
|
|
return None
|
|
|
|
|
|
async def validate_task_ownership(
|
|
task: dict, agent_id: str, client: ApiClient
|
|
) -> dict[str, Any] | None:
|
|
"""Validate agent owns the task."""
|
|
assigned_to = task.get("assigned_to")
|
|
if not assigned_to:
|
|
return format_error_response("NOT_ASSIGNED", "Task is not assigned to anyone")
|
|
|
|
agent_uuid = await resolve_agent_uuid_cached(agent_id, client)
|
|
if not agent_uuid:
|
|
return format_error_response(
|
|
"AGENT_NOT_FOUND", f"Could not resolve agent: {agent_id}"
|
|
)
|
|
|
|
if str(assigned_to) != agent_uuid:
|
|
return format_error_response(
|
|
"NOT_OWNER",
|
|
"You are not assigned to this task",
|
|
{"assigned_to": assigned_to},
|
|
)
|
|
return None
|
|
|
|
|
|
def validate_task_status_claimed(task: dict) -> dict[str, Any] | None:
|
|
"""Validate task is in claimed status."""
|
|
if task.get("status") != "claimed":
|
|
return format_error_response(
|
|
"INVALID_STATE",
|
|
f"Cannot submit plan for task in '{task.get('status')}' status. "
|
|
"Task must be 'claimed'.",
|
|
{"current_status": task.get("status")},
|
|
)
|
|
return None
|
|
|
|
|
|
def build_plan_data(plan_params: dict[str, Any]) -> dict[str, Any]:
|
|
"""Build the plan data structure from params.
|
|
|
|
Supports two formats for open_questions:
|
|
- List of strings: ["Question 1", "Question 2"] -> sets answered=False
|
|
- List of dicts: [{"question": "Q1", "answered": True}] -> preserves status
|
|
"""
|
|
raw_questions = plan_params.get("open_questions") or []
|
|
open_questions = []
|
|
for q in raw_questions:
|
|
if isinstance(q, str):
|
|
# Simple string format - new unanswered question
|
|
open_questions.append({"question": q, "answered": False})
|
|
elif isinstance(q, dict):
|
|
# Dict format - preserve answered status if provided
|
|
open_questions.append(
|
|
{
|
|
"question": q.get("question", ""),
|
|
"answered": q.get("answered", False),
|
|
"answer": q.get("answer"), # Optional: store the answer text
|
|
}
|
|
)
|
|
|
|
return {
|
|
"approach": plan_params["approach"],
|
|
"sub_tasks": [
|
|
{
|
|
"id": st.get("id") or str(uuid4()),
|
|
"title": st.get("title", ""),
|
|
"description": st.get("description", ""),
|
|
"order": i,
|
|
}
|
|
for i, st in enumerate(plan_params["sub_tasks"])
|
|
],
|
|
"risks": [{"description": r} for r in (plan_params.get("risks") or [])],
|
|
"open_questions": open_questions,
|
|
}
|
|
|
|
|
|
async def validate_task_start(
|
|
task: dict[str, Any], agent_id: str, client: ApiClient
|
|
) -> dict[str, Any] | None:
|
|
"""Validate task can be started."""
|
|
if error := await validate_task_ownership(task, agent_id, client):
|
|
return error
|
|
|
|
task_status = task.get("status")
|
|
valid_start_statuses = ["claimed", "paused", "needs_revision"]
|
|
if task_status not in valid_start_statuses:
|
|
return format_error_response(
|
|
"INVALID_STATE",
|
|
f"Cannot start task in '{task_status}' status. "
|
|
"Task must be 'claimed', 'paused', or 'needs_revision'.",
|
|
{"current_status": task_status},
|
|
)
|
|
|
|
if task_status == "claimed" and not task.get("plan"):
|
|
return format_error_response(
|
|
"NO_PLAN",
|
|
"Cannot start without a plan.",
|
|
{
|
|
"required_action": "roboco_task_plan(task_id, approach, steps)",
|
|
"workflow": "claim → PLAN → start",
|
|
"example": {
|
|
"task_id": task.get("id"),
|
|
"approach": "Describe your implementation approach",
|
|
"steps": [
|
|
{"title": "Step 1", "description": "What to do first"},
|
|
{"title": "Step 2", "description": "What to do next"},
|
|
],
|
|
},
|
|
},
|
|
)
|
|
|
|
if task_status == "claimed":
|
|
plan = task.get("plan", {})
|
|
unanswered = [
|
|
q for q in plan.get("open_questions", []) if not q.get("answered")
|
|
]
|
|
if unanswered:
|
|
return format_error_response(
|
|
"UNANSWERED_QUESTIONS",
|
|
f"Cannot start with {len(unanswered)} unanswered question(s). "
|
|
"Get answers first, then update the plan.",
|
|
{"questions": [q.get("question") for q in unanswered]},
|
|
)
|
|
|
|
return None
|