mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Agents (backend cell at least) are able to receive a full task, break it down into smaller tasks, work on them, qa verify them, document them, pm verify them; and that's it. Completed task.
This commit is contained in:
+68
-60
@@ -4,11 +4,18 @@ Notify MCP Server
|
||||
Exposes notification tools to Claude Code agents with built-in
|
||||
enforcement of notification permissions.
|
||||
|
||||
Tools:
|
||||
Tools available to ALL agents:
|
||||
- roboco_notify_list: List your notifications
|
||||
- roboco_notify_get: Get a specific notification
|
||||
- roboco_notify_ack: Acknowledge a notification
|
||||
- roboco_notify_send: Send a notification (PM/Board/Auditor only)
|
||||
|
||||
Tools available ONLY to PM/Board/Auditor:
|
||||
- roboco_notify_send: Send a notification
|
||||
- roboco_escalate: Escalate an issue (PMs only)
|
||||
- roboco_request_approval: Request approval (PMs/Board only)
|
||||
|
||||
Note: Developers, QA, and Documenters do not see the sending tools.
|
||||
They should use message channels and blocker reporting instead.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -18,6 +25,7 @@ from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from roboco.agents_config import (
|
||||
NOTIFICATION_PERMISSIONS,
|
||||
can_send_notifications,
|
||||
get_agent_cell,
|
||||
get_agent_role,
|
||||
)
|
||||
@@ -301,71 +309,71 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
|
||||
"""Acknowledge a notification."""
|
||||
return await _handle_ack(client, notification_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
|
||||
"""
|
||||
Send a notification to one or more agents.
|
||||
# Only register send/escalate/approval tools for agents who can send notifications
|
||||
# This prevents developers, QA, and documenters from even seeing these tools
|
||||
if can_send_notifications(agent_id):
|
||||
|
||||
Only PMs, Board members, and Auditor can send notifications.
|
||||
Cell PMs can only notify their own cell.
|
||||
"""
|
||||
return await _handle_send(client, agent_id, data)
|
||||
@mcp.tool()
|
||||
async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]:
|
||||
"""
|
||||
Send a notification to one or more agents.
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_escalate(
|
||||
escalate_to: str,
|
||||
subject: str,
|
||||
description: str,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Escalate an issue to a higher level (PM only).
|
||||
Cell PMs can only notify their own cell.
|
||||
Main PM, Board, and Auditor can notify anyone.
|
||||
"""
|
||||
return await _handle_send(client, agent_id, data)
|
||||
|
||||
Sends a high-priority notification requiring acknowledgment.
|
||||
"""
|
||||
role = get_agent_role(agent_id)
|
||||
if role not in ["cell_pm", "main_pm"]:
|
||||
return format_error_response(
|
||||
"NOT_PM", "Only PMs can use the escalate function"
|
||||
)
|
||||
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[escalate_to],
|
||||
subject=f"[ESCALATION] {subject}",
|
||||
body=description,
|
||||
notification_type="escalation",
|
||||
priority="high",
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return await _handle_send(client, agent_id, input_data)
|
||||
# Only PMs can escalate
|
||||
if role in ["cell_pm", "main_pm"]:
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_request_approval(
|
||||
approver: str,
|
||||
subject: str,
|
||||
what_needs_approval: str,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Request approval from someone (PM/Board only).
|
||||
"""
|
||||
role = get_agent_role(agent_id)
|
||||
if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
||||
return format_error_response(
|
||||
"NOT_AUTHORIZED", "Only PMs and Board can request approvals"
|
||||
)
|
||||
@mcp.tool()
|
||||
async def roboco_escalate(
|
||||
escalate_to: str,
|
||||
subject: str,
|
||||
description: str,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Escalate an issue to a higher level.
|
||||
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[approver],
|
||||
subject=f"[APPROVAL NEEDED] {subject}",
|
||||
body=what_needs_approval,
|
||||
notification_type="approval",
|
||||
priority="normal",
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return await _handle_send(client, agent_id, input_data)
|
||||
Sends a high-priority notification requiring acknowledgment.
|
||||
"""
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[escalate_to],
|
||||
subject=f"[ESCALATION] {subject}",
|
||||
body=description,
|
||||
notification_type="escalation",
|
||||
priority="high",
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return await _handle_send(client, agent_id, input_data)
|
||||
|
||||
# Only PMs and Board can request approvals
|
||||
if role in ["cell_pm", "main_pm", "product_owner", "head_marketing"]:
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_request_approval(
|
||||
approver: str,
|
||||
subject: str,
|
||||
what_needs_approval: str,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Request approval from someone.
|
||||
"""
|
||||
input_data = SendNotificationInput(
|
||||
recipients=[approver],
|
||||
subject=f"[APPROVAL NEEDED] {subject}",
|
||||
body=what_needs_approval,
|
||||
notification_type="approval",
|
||||
priority="normal",
|
||||
requires_ack=True,
|
||||
related_task_id=task_id,
|
||||
)
|
||||
return await _handle_send(client, agent_id, input_data)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
+326
-104
@@ -17,9 +17,11 @@ Tools:
|
||||
- roboco_task_submit_qa: Submit for QA review
|
||||
- roboco_task_qa_pass: Pass QA (QA role only)
|
||||
- roboco_task_qa_fail: Fail QA (QA role only)
|
||||
- roboco_task_complete: Mark task complete
|
||||
- roboco_task_docs_complete: Mark docs complete (Documenter only)
|
||||
- roboco_task_complete: Mark task complete (PM only, after docs)
|
||||
- roboco_task_create: Create new task (PM only)
|
||||
- roboco_task_assign: Assign task to agent (PM only)
|
||||
- roboco_task_cancel: Cancel a task (PM/Board only)
|
||||
- roboco_task_escalate: Escalate task up hierarchy (all agents)
|
||||
"""
|
||||
|
||||
@@ -30,6 +32,7 @@ from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from roboco.agents_config import (
|
||||
can_assign_tasks,
|
||||
can_cancel_tasks,
|
||||
can_create_tasks,
|
||||
get_agent_role,
|
||||
get_agent_team,
|
||||
@@ -70,6 +73,10 @@ async def _resolve_agent_uuid_cached(agent_id: str, client: ApiClient) -> str |
|
||||
# Global TOON adapter for encoding task data
|
||||
_toon = ToonAdapter()
|
||||
|
||||
# Progress percentage bounds
|
||||
_MIN_PERCENTAGE = 0
|
||||
_MAX_PERCENTAGE = 100
|
||||
|
||||
# NOTE: For task lifecycle validation, use enforcement.task_lifecycle.VALID_TRANSITIONS
|
||||
|
||||
|
||||
@@ -166,6 +173,57 @@ def _get_next_step_guidance(status: str) -> tuple[str, str]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_available_tasks_guidance(
|
||||
available_tasks: list[dict[str, Any]], agent_role: str
|
||||
) -> str:
|
||||
"""Generate guidance for available tasks based on agent role."""
|
||||
review_count = sum(
|
||||
1 for t in available_tasks if t.get("status") == "awaiting_pm_review"
|
||||
)
|
||||
pending_count = len(available_tasks) - review_count
|
||||
|
||||
if agent_role in ("cell_pm", "main_pm") and review_count > 0:
|
||||
return (
|
||||
f"Found {review_count} task(s) awaiting your review. "
|
||||
"Use roboco_task_get to review, then roboco_task_complete to finalize. "
|
||||
f"Also {pending_count} pending task(s) need triage."
|
||||
)
|
||||
return (
|
||||
f"Found {len(available_tasks)} available task(s). "
|
||||
"Review and claim one that matches your skills."
|
||||
)
|
||||
|
||||
|
||||
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"):
|
||||
# PMs get pending tasks AND tasks awaiting their review
|
||||
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
|
||||
|
||||
# Developers get pending tasks only
|
||||
resp = await client.get("/tasks", params={**params, "status": "pending"})
|
||||
return resp.json() if resp.ok else []
|
||||
|
||||
|
||||
async def _handle_task_scan(
|
||||
client: ApiClient, team: str | None, agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -186,42 +244,15 @@ async def _handle_task_scan(
|
||||
]
|
||||
|
||||
# Get available tasks based on agent role
|
||||
# QA agents need awaiting_qa tasks, Documenters need awaiting_documentation
|
||||
agent_role = get_agent_role(agent_id)
|
||||
|
||||
available_tasks: list[dict[str, Any]] = []
|
||||
|
||||
if agent_role == "qa":
|
||||
# QA agents look for tasks awaiting QA review
|
||||
qa_resp = await client.get(
|
||||
"/tasks/awaiting-qa",
|
||||
params={"team": team} if team else {},
|
||||
)
|
||||
if qa_resp.ok:
|
||||
available_tasks = qa_resp.json()
|
||||
elif agent_role == "documenter":
|
||||
# Documenters look for tasks awaiting documentation
|
||||
doc_resp = await client.get(
|
||||
"/tasks/awaiting-docs",
|
||||
params={"team": team} if team else {},
|
||||
)
|
||||
if doc_resp.ok:
|
||||
available_tasks = doc_resp.json()
|
||||
else:
|
||||
# Developers and PMs look for pending tasks
|
||||
params: dict[str, Any] = {"status": "pending"}
|
||||
if team:
|
||||
params["team"] = team
|
||||
pending_resp = await client.get("/tasks", params=params)
|
||||
if pending_resp.ok:
|
||||
available_tasks = pending_resp.json()
|
||||
available_tasks = await _get_available_tasks_for_role(client, agent_role, team)
|
||||
|
||||
# Filter out tasks already in assigned_tasks from available_tasks
|
||||
# (prevents PM-assigned pending tasks from appearing in both lists)
|
||||
assigned_ids = {t.get("id") for t in assigned_tasks}
|
||||
available_tasks = [t for t in available_tasks if t.get("id") not in assigned_ids]
|
||||
|
||||
# Determine guidance
|
||||
# Determine guidance based on role and available tasks
|
||||
if paused_tasks:
|
||||
guidance = (
|
||||
f"You have {len(paused_tasks)} paused task(s). "
|
||||
@@ -233,10 +264,7 @@ async def _handle_task_scan(
|
||||
"Continue working on your assigned tasks."
|
||||
)
|
||||
elif available_tasks:
|
||||
guidance = (
|
||||
f"Found {len(available_tasks)} available task(s). "
|
||||
"Review and claim one that matches your skills."
|
||||
)
|
||||
guidance = _get_available_tasks_guidance(available_tasks, agent_role)
|
||||
else:
|
||||
guidance = (
|
||||
"No tasks available. Call roboco_agent_idle() "
|
||||
@@ -492,30 +520,40 @@ async def _validate_task_start(
|
||||
return error
|
||||
|
||||
task_status = task.get("status")
|
||||
if task_status not in ["claimed", "paused"]:
|
||||
# Valid statuses to start/resume work:
|
||||
# - claimed: Developer just claimed a pending task
|
||||
# - paused: Developer resuming paused work
|
||||
# - needs_revision: Developer resuming after QA rejection
|
||||
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' or 'paused'.",
|
||||
"Task must be 'claimed', 'paused', or 'needs_revision'.",
|
||||
{"current_status": task_status},
|
||||
)
|
||||
|
||||
# Only require plan for newly claimed tasks, not for resuming revision
|
||||
if task_status == "claimed" and not task.get("plan"):
|
||||
return _format_error_response(
|
||||
"NO_PLAN",
|
||||
"Cannot start without a plan. Call roboco_task_plan first.",
|
||||
)
|
||||
|
||||
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]},
|
||||
)
|
||||
# Only check open questions for newly claimed tasks
|
||||
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
|
||||
|
||||
@@ -559,10 +597,17 @@ async def _handle_task_progress(
|
||||
client: ApiClient,
|
||||
task_id: str,
|
||||
message: str,
|
||||
percentage: int | None,
|
||||
percentage: int,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task progress update."""
|
||||
# Validate percentage is in valid range
|
||||
if not _MIN_PERCENTAGE <= percentage <= _MAX_PERCENTAGE:
|
||||
return _format_error_response(
|
||||
"INVALID_PERCENTAGE",
|
||||
f"Percentage must be between {_MIN_PERCENTAGE} and {_MAX_PERCENTAGE}",
|
||||
)
|
||||
|
||||
task_resp = await client.get(f"/tasks/{task_id}")
|
||||
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||
@@ -572,10 +617,17 @@ async def _handle_task_progress(
|
||||
if error := await _validate_task_ownership(task, agent_id, client):
|
||||
return error
|
||||
|
||||
if task.get("status") != "in_progress":
|
||||
# Allow progress updates for active work statuses
|
||||
active_statuses = {
|
||||
"in_progress",
|
||||
"verifying",
|
||||
"awaiting_qa",
|
||||
"awaiting_documentation",
|
||||
}
|
||||
if task.get("status") not in active_statuses:
|
||||
return _format_error_response(
|
||||
"INVALID_STATE",
|
||||
"Can only update progress for in_progress tasks",
|
||||
f"Can only update progress for active tasks. Current: {task.get('status')}",
|
||||
)
|
||||
|
||||
# Add progress update
|
||||
@@ -608,7 +660,7 @@ async def _handle_task_block(
|
||||
data: TaskBlockInput,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task blocking."""
|
||||
"""Handle task blocking via the soft-block endpoint."""
|
||||
task_resp = await client.get(f"/tasks/{data.task_id}")
|
||||
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
return _format_error_response("NOT_FOUND", f"Task {data.task_id} not found")
|
||||
@@ -624,24 +676,13 @@ async def _handle_task_block(
|
||||
"Can only block in_progress tasks",
|
||||
)
|
||||
|
||||
# Build blocker note for dev_notes
|
||||
blocker_note = (
|
||||
f"[BLOCKED - {data.blocker_type.upper()}]\n"
|
||||
f"Reason: {data.reason}\n"
|
||||
f"What's needed: {data.what_needed}"
|
||||
)
|
||||
existing_notes = task.get("dev_notes") or ""
|
||||
if existing_notes:
|
||||
updated_notes = f"{existing_notes}\n\n{blocker_note}"
|
||||
else:
|
||||
updated_notes = blocker_note
|
||||
|
||||
# Block the task using PATCH to update status and notes
|
||||
block_resp = await client.patch(
|
||||
f"/tasks/{data.task_id}",
|
||||
# Use the soft-block endpoint which handles status change and notes
|
||||
block_resp = await client.post(
|
||||
f"/tasks/{data.task_id}/soft-block",
|
||||
json={
|
||||
"status": "blocked",
|
||||
"dev_notes": updated_notes,
|
||||
"reason": data.reason,
|
||||
"blocker_type": data.blocker_type,
|
||||
"what_needed": data.what_needed,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -813,46 +854,50 @@ async def _handle_task_submit_qa(
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task QA submission."""
|
||||
# Validate inputs
|
||||
if not dev_notes or not handoff_summary:
|
||||
return _format_error_response(
|
||||
"MISSING_NOTES",
|
||||
"Both dev_notes and handoff_summary are required for QA submission.",
|
||||
)
|
||||
|
||||
# Validate task exists and ownership
|
||||
task_resp = await client.get(f"/tasks/{task_id}")
|
||||
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||
|
||||
task = task_resp.json()
|
||||
|
||||
if error := await _validate_task_ownership(task, agent_id, client):
|
||||
return error
|
||||
|
||||
# Validate state
|
||||
if task.get("status") != "verifying":
|
||||
return _format_error_response(
|
||||
"INVALID_STATE",
|
||||
"Can only submit verified tasks for QA",
|
||||
"INVALID_STATE", "Can only submit verified tasks for QA"
|
||||
)
|
||||
|
||||
# Update with notes - combine dev_notes and handoff summary
|
||||
# (handoff summary goes into dev_notes for documenter to read)
|
||||
# Save dev notes and handoff summary, then submit for QA
|
||||
combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}"
|
||||
await client.patch(f"/tasks/{task_id}", json={"dev_notes": combined_notes})
|
||||
notes_resp = await client.patch(
|
||||
f"/tasks/{task_id}", json={"dev_notes": combined_notes}
|
||||
)
|
||||
if not notes_resp.ok:
|
||||
return _format_error_response(
|
||||
"NOTES_SAVE_FAILED",
|
||||
"Failed to save dev notes. QA submission aborted.",
|
||||
)
|
||||
|
||||
# Submit for QA
|
||||
qa_resp = await client.post(f"/tasks/{task_id}/submit-qa")
|
||||
|
||||
if not qa_resp.ok:
|
||||
return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
|
||||
|
||||
qa_task = qa_resp.json()
|
||||
|
||||
return _format_task_response(
|
||||
qa_task,
|
||||
"WAIT_FOR_QA",
|
||||
"Task submitted for QA review.\n"
|
||||
"You will be notified of the result.\n"
|
||||
"In the meantime, call roboco_task_scan for other work.",
|
||||
return (
|
||||
_format_task_response(
|
||||
qa_resp.json(),
|
||||
"WAIT_FOR_QA",
|
||||
"Task submitted for QA review.\n"
|
||||
"You will be notified of the result.\n"
|
||||
"In the meantime, call roboco_task_scan for other work.",
|
||||
)
|
||||
if qa_resp.ok
|
||||
else _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
|
||||
)
|
||||
|
||||
|
||||
@@ -863,11 +908,13 @@ async def _handle_task_qa_pass(
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task QA pass."""
|
||||
# Check if agent has QA role (simple check - real impl would verify)
|
||||
if "qa" not in agent_id.lower():
|
||||
# Check if agent has QA role using canonical role lookup
|
||||
agent_role = get_agent_role(agent_id)
|
||||
if agent_role != "qa":
|
||||
return _format_error_response(
|
||||
"NOT_QA",
|
||||
"Only QA agents can pass tasks through QA review.",
|
||||
{"your_role": agent_role},
|
||||
)
|
||||
|
||||
task_resp = await client.get(f"/tasks/{task_id}")
|
||||
@@ -925,10 +972,13 @@ async def _handle_task_qa_fail(
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task QA failure."""
|
||||
if "qa" not in agent_id.lower():
|
||||
# Check if agent has QA role using canonical role lookup
|
||||
agent_role = get_agent_role(agent_id)
|
||||
if agent_role != "qa":
|
||||
return _format_error_response(
|
||||
"NOT_QA",
|
||||
"Only QA agents can fail tasks in QA review.",
|
||||
{"your_role": agent_role},
|
||||
)
|
||||
|
||||
if not issues:
|
||||
@@ -974,8 +1024,22 @@ async def _handle_task_qa_fail(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, Any]:
|
||||
"""Handle task completion."""
|
||||
async def _handle_docs_complete(
|
||||
client: ApiClient,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
doc_notes: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle documentation completion (documenter only)."""
|
||||
# Check if agent is a documenter
|
||||
agent_role = get_agent_role(agent_id)
|
||||
if agent_role != "documenter":
|
||||
return _format_error_response(
|
||||
"NOT_DOCUMENTER",
|
||||
"Only documenters can mark documentation as complete.",
|
||||
{"your_role": agent_role},
|
||||
)
|
||||
|
||||
task_resp = await client.get(f"/tasks/{task_id}")
|
||||
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||
@@ -985,7 +1049,56 @@ async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, An
|
||||
if task.get("status") != "awaiting_documentation":
|
||||
return _format_error_response(
|
||||
"INVALID_STATE",
|
||||
"Task must be awaiting documentation to complete",
|
||||
"Task must be awaiting documentation to mark docs complete",
|
||||
)
|
||||
|
||||
payload = {"notes": doc_notes} if doc_notes else {}
|
||||
docs_resp = await client.post(f"/tasks/{task_id}/docs-complete", json=payload)
|
||||
|
||||
if not docs_resp.ok:
|
||||
return _format_error_response(
|
||||
"DOCS_COMPLETE_FAILED",
|
||||
"Failed to mark documentation complete",
|
||||
{"status_code": docs_resp.status_code, "api_error": docs_resp.text},
|
||||
)
|
||||
|
||||
updated_task = docs_resp.json()
|
||||
|
||||
return _format_task_response(
|
||||
updated_task,
|
||||
"AWAITING_PM",
|
||||
"Documentation complete! Task is now awaiting PM review.\n"
|
||||
"The Cell PM will review and complete the task.\n"
|
||||
"Call roboco_task_scan for next documentation task.",
|
||||
)
|
||||
|
||||
|
||||
async def _handle_task_complete(
|
||||
client: ApiClient,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task completion (PM only)."""
|
||||
# Check if agent can complete tasks (PM role)
|
||||
if not can_cancel_tasks(agent_id): # Same roles that can cancel can complete
|
||||
role = get_agent_role(agent_id)
|
||||
return _format_error_response(
|
||||
"NOT_PM",
|
||||
"Only PMs can complete tasks after reviewing.",
|
||||
{"your_role": role},
|
||||
)
|
||||
|
||||
task_resp = await client.get(f"/tasks/{task_id}")
|
||||
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||
|
||||
task = task_resp.json()
|
||||
|
||||
if task.get("status") != "awaiting_pm_review":
|
||||
return _format_error_response(
|
||||
"INVALID_STATE",
|
||||
"Task must be awaiting PM review to complete. "
|
||||
"Documenter should call roboco_task_docs_complete first.",
|
||||
)
|
||||
|
||||
complete_resp = await client.post(f"/tasks/{task_id}/complete")
|
||||
@@ -994,10 +1107,7 @@ async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, An
|
||||
return _format_error_response(
|
||||
"COMPLETE_FAILED",
|
||||
"Failed to complete task",
|
||||
{
|
||||
"status_code": complete_resp.status_code,
|
||||
"api_error": complete_resp.text,
|
||||
},
|
||||
{"status_code": complete_resp.status_code, "api_error": complete_resp.text},
|
||||
)
|
||||
|
||||
completed_task = complete_resp.json()
|
||||
@@ -1005,7 +1115,60 @@ async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, An
|
||||
return _format_task_response(
|
||||
completed_task,
|
||||
"DONE",
|
||||
"Task completed successfully!\nCall roboco_task_scan for new work.",
|
||||
"Task completed successfully!\nCall roboco_task_scan for more work.",
|
||||
)
|
||||
|
||||
|
||||
async def _handle_task_cancel(
|
||||
client: ApiClient,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task cancellation (PM and board only)."""
|
||||
# Check permission first
|
||||
if not can_cancel_tasks(agent_id):
|
||||
role = get_agent_role(agent_id)
|
||||
return _format_error_response(
|
||||
"NOT_AUTHORIZED",
|
||||
"Only PMs and board members can cancel tasks",
|
||||
{"your_role": role},
|
||||
)
|
||||
|
||||
# Get task to verify it exists
|
||||
task_resp = await client.get(f"/tasks/{task_id}")
|
||||
if not task_resp.ok:
|
||||
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
|
||||
|
||||
task = task_resp.json()
|
||||
current_status = task.get("status")
|
||||
|
||||
# Terminal states can't be cancelled
|
||||
if current_status in ("completed", "cancelled"):
|
||||
return _format_error_response(
|
||||
"INVALID_STATE",
|
||||
f"Cannot cancel task in '{current_status}' status",
|
||||
)
|
||||
|
||||
# Cancel the task
|
||||
cancel_resp = await client.post(f"/tasks/{task_id}/cancel")
|
||||
|
||||
if not cancel_resp.ok:
|
||||
return _format_error_response(
|
||||
"CANCEL_FAILED",
|
||||
"Failed to cancel task",
|
||||
{
|
||||
"status_code": cancel_resp.status_code,
|
||||
"api_error": cancel_resp.text,
|
||||
},
|
||||
)
|
||||
|
||||
cancelled_task = cancel_resp.json()
|
||||
|
||||
return _format_task_response(
|
||||
cancelled_task,
|
||||
"CANCELLED",
|
||||
f"Task cancelled.{' Reason: ' + reason if reason else ''}",
|
||||
)
|
||||
|
||||
|
||||
@@ -1498,15 +1661,19 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
async def roboco_task_progress(
|
||||
task_id: str,
|
||||
message: str,
|
||||
percentage: int | None = None,
|
||||
percentage: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Update task progress.
|
||||
|
||||
ENFORCEMENT:
|
||||
- Percentage is REQUIRED (0-100) to show real progress
|
||||
- Message must describe what was accomplished
|
||||
|
||||
Args:
|
||||
task_id: The task UUID
|
||||
message: Progress update message
|
||||
percentage: Optional completion percentage (0-100)
|
||||
message: Progress update message describing work done
|
||||
percentage: Completion percentage (0-100), required
|
||||
|
||||
Returns:
|
||||
Updated task
|
||||
@@ -1685,13 +1852,41 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
return await _handle_task_qa_fail(client, task_id, qa_notes, issues, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_complete(task_id: str) -> dict[str, Any]:
|
||||
async def roboco_task_docs_complete(
|
||||
task_id: str,
|
||||
doc_notes: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Mark task as completed (typically by Documenter).
|
||||
Mark documentation as complete (documenter only).
|
||||
|
||||
Transitions task from awaiting_documentation to awaiting_pm_review.
|
||||
The Cell PM will then review and complete the task.
|
||||
|
||||
ENFORCEMENT:
|
||||
- Only documenters can use this tool
|
||||
- Task must be in 'awaiting_documentation' status
|
||||
- Documentation must exist
|
||||
- Cannot document your own task (self-review prevention)
|
||||
|
||||
Args:
|
||||
task_id: The task UUID
|
||||
doc_notes: Optional notes about the documentation completed
|
||||
|
||||
Returns:
|
||||
Task now awaiting PM review
|
||||
"""
|
||||
return await _handle_docs_complete(client, task_id, agent_id, doc_notes)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_complete(task_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Mark task as completed (PM only).
|
||||
|
||||
Only PMs can complete tasks, after documenter marks docs complete.
|
||||
This is the final step in the workflow: Dev → QA → Documenter → PM.
|
||||
|
||||
ENFORCEMENT:
|
||||
- Only PMs can use this tool
|
||||
- Task must be in 'awaiting_pm_review' status
|
||||
|
||||
Args:
|
||||
task_id: The task UUID
|
||||
@@ -1699,7 +1894,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
Returns:
|
||||
Completed task
|
||||
"""
|
||||
return await _handle_task_complete(client, task_id)
|
||||
return await _handle_task_complete(client, task_id, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_agent_idle() -> dict[str, Any]:
|
||||
@@ -1770,6 +1965,33 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
|
||||
input_data = TaskAssignInput(task_id=task_id, assignee=assignee)
|
||||
return await _handle_task_assign(client, input_data, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_cancel(
|
||||
task_id: str,
|
||||
reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Cancel a task (PM and board only).
|
||||
|
||||
Use this to:
|
||||
- Cancel obsolete or duplicate tasks
|
||||
- Cancel tasks that are no longer needed
|
||||
- Cancel blocked tasks that cannot be resolved
|
||||
|
||||
ENFORCEMENT:
|
||||
- Only PMs and board members can cancel tasks
|
||||
- CEO and Auditor cannot cancel (they observe only)
|
||||
- Cannot cancel completed or already-cancelled tasks
|
||||
|
||||
Args:
|
||||
task_id: The task UUID to cancel
|
||||
reason: Optional reason for cancellation
|
||||
|
||||
Returns:
|
||||
Cancelled task confirmation
|
||||
"""
|
||||
return await _handle_task_cancel(client, task_id, agent_id, reason)
|
||||
|
||||
@mcp.tool()
|
||||
async def roboco_task_escalate(
|
||||
task_id: str,
|
||||
|
||||
+2
-2
@@ -97,11 +97,11 @@ async def resolve_agent_uuid(
|
||||
if len(agent_id) == _UUID_LENGTH and agent_id.count("-") == _UUID_HYPHEN_COUNT:
|
||||
return agent_id
|
||||
|
||||
# Look up by slug
|
||||
# Look up by slug - GET /agents/{id} accepts both UUID and slug
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{settings.internal_api_url}/agents/by-slug/{agent_id}",
|
||||
f"{settings.internal_api_url}/agents/{agent_id}",
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code == _HTTP_OK:
|
||||
|
||||
Reference in New Issue
Block a user