It works pretty nicely with the ultimate test

This commit is contained in:
Renn F
2025-12-27 00:34:11 +01:00
parent 8c3bf9e22c
commit d2034e538f
15 changed files with 371 additions and 109 deletions
+8
View File
@@ -27,6 +27,7 @@ different purposes. MCP is coarse-grained (tool-level), API is fine-grained
from typing import Final from typing import Final
from roboco.models.base import NotificationPriority, NotificationType
from roboco.seeds.initial_data import AGENT_UUIDS from roboco.seeds.initial_data import AGENT_UUIDS
# Reverse mapping: UUID -> slug (computed from seeds) # Reverse mapping: UUID -> slug (computed from seeds)
@@ -390,3 +391,10 @@ NOTIFICATION_PERMISSIONS: Final[dict[str, dict]] = {
"can_send": False, "can_send": False,
}, },
} }
VALID_NOTIFICATION_TYPES: Final[frozenset[str]] = frozenset(
t.value for t in NotificationType
)
VALID_NOTIFICATION_PRIORITIES: Final[frozenset[str]] = frozenset(
p.value for p in NotificationPriority
)
+33 -29
View File
@@ -4,7 +4,7 @@ Task API Routes
Full CRUD operations and lifecycle management for tasks. Full CRUD operations and lifecycle management for tasks.
""" """
from typing import Annotated from typing import Annotated, Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status from fastapi import APIRouter, Body, HTTPException, Query, status
@@ -25,6 +25,7 @@ from roboco.api.schemas.tasks import (
CheckpointRequest, CheckpointRequest,
ClaimRequest, ClaimRequest,
CommitRequest, CommitRequest,
CompleteTaskRequest,
EscalateRequest, EscalateRequest,
EscalateResponse, EscalateResponse,
ProgressRequest, ProgressRequest,
@@ -967,12 +968,17 @@ async def complete_task(
db: DbSession, db: DbSession,
agent: CurrentAgentContext, agent: CurrentAgentContext,
permissions: PermissionServiceDep, permissions: PermissionServiceDep,
data: Annotated[CompleteTaskRequest | None, Body()] = None,
) -> TaskResponse: ) -> TaskResponse:
"""Mark task as completed (PM only). """Mark task as completed (PM only).
Two completion paths: Two completion paths:
1. Developer work: task must be in awaiting_pm_review (went through QA/Docs) 1. Developer work: task must be in awaiting_pm_review (went through QA/Docs)
2. PM's own task: task can be in_progress if assigned to the completing PM 2. PM's own task: task can be in_progress if assigned to the completing PM
PM Override for cancelled subtasks:
If force_with_cancelled=True, PM can complete despite cancelled subtasks.
Requires justification. Does NOT apply to pending/in_progress subtasks.
""" """
service = get_task_service(db) service = get_task_service(db)
task = await service.get(task_id) task = await service.get(task_id)
@@ -981,7 +987,7 @@ async def complete_task(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
) )
# Only PMs can complete tasks # Permission check - only PMs can complete tasks
can_close = permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team) can_close = permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team)
if not can_close: if not can_close:
raise HTTPException( raise HTTPException(
@@ -989,38 +995,28 @@ async def complete_task(
detail="Only PMs can complete tasks", detail="Only PMs can complete tasks",
) )
# Check ALL subtasks are completed before completing parent # Extract request data
# Cancelled subtasks block completion - they must be resolved first force_complete = data.force_with_cancelled if data else False
subtasks = await service.get_subtasks(task_id) justification = data.justification if data else None
incomplete_subtasks = [
st # Validate justification if force is requested
for st in subtasks if force_complete and not justification:
if st.status != TaskStatus.COMPLETED
]
if incomplete_subtasks:
max_titles_shown = 3
incomplete_info = [
f"{st.title} ({st.status.value})"
for st in incomplete_subtasks[:max_titles_shown]
]
detail = (
f"Cannot complete task - {len(incomplete_subtasks)} subtask(s) "
f"not completed: {', '.join(incomplete_info)}"
)
if len(incomplete_subtasks) > max_titles_shown:
detail += f" (+{len(incomplete_subtasks) - max_titles_shown} more)"
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=detail, detail="force_with_cancelled requires justification",
) )
# Pass agent_id so service can check if PM is completing their own task task = await service.complete(
task = await service.complete(task_id, agent_id=agent.agent_id) task_id,
agent_id=agent.agent_id,
force_with_cancelled=force_complete,
justification=justification,
)
if not task: if not task:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot complete task - must be in awaiting_pm_review or " detail="Cannot complete task - check status and subtasks. "
"in_progress (if your own task)", "Use force_with_cancelled=true if only cancelled subtasks remain.",
) )
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -1234,12 +1230,20 @@ async def substitute_task(
# Determine new status based on reason # Determine new status based on reason
new_status = _REASON_TO_STATUS.get(reason, TaskStatus.PENDING) new_status = _REASON_TO_STATUS.get(reason, TaskStatus.PENDING)
# Update task # QA/Documenter completing their own work goes to PM review (can't self-review)
update_data = { if reason == SubstituteReason.TASK_COMPLETE and agent.role in ("qa", "documenter"):
new_status = TaskStatus.AWAITING_PM_REVIEW
# Preserve original developer for self-review prevention when going to QA
update_data: dict[str, Any] = {
"status": new_status.value, "status": new_status.value,
"assigned_to": None, # Clear assignment "assigned_to": None, # Clear assignment
"dev_notes": f"[SUBSTITUTE] Reason: {reason.value}\n{data.details}", "dev_notes": f"[SUBSTITUTE] Reason: {reason.value}\n{data.details}",
} }
if new_status == TaskStatus.AWAITING_QA and task.assigned_to:
# Set original_developer BEFORE clearing assigned_to
update_data["quick_context"] = f"original_developer:{task.assigned_to}"
task = await service.update(task_id, **update_data) task = await service.update(task_id, **update_data)
if not task: if not task:
raise HTTPException( raise HTTPException(
+16
View File
@@ -303,6 +303,22 @@ class QANotes(BaseModel):
notes: str notes: str
class CompleteTaskRequest(BaseModel):
"""Request to complete a task with optional force flag."""
force_with_cancelled: bool = Field(
default=False,
description="Force complete even if some subtasks are cancelled. "
"PM takes responsibility for judging work is done. "
"Only applies to cancelled subtasks, not pending/in_progress.",
)
justification: str | None = Field(
default=None,
description="Required when force_with_cancelled=True. "
"PM's justification for completing despite cancelled subtasks.",
)
class SoftBlockRequest(BaseModel): class SoftBlockRequest(BaseModel):
"""Request to soft-block a task due to an external factor.""" """Request to soft-block a task due to an external factor."""
+6 -4
View File
@@ -34,8 +34,8 @@ VALID_TRANSITIONS: dict[str, list[str]] = {
"awaiting_documentation", "awaiting_documentation",
"cancelled", "cancelled",
], ],
# Needs revision - back to work or cancel # Needs revision - developer claims, works, or PM cancels
"needs_revision": ["in_progress", "cancelled"], "needs_revision": ["claimed", "in_progress", "cancelled"],
# Awaiting QA - QA claims, passes, fails, or blocks # Awaiting QA - QA claims, passes, fails, or blocks
"awaiting_qa": [ "awaiting_qa": [
"claimed", "claimed",
@@ -46,8 +46,8 @@ VALID_TRANSITIONS: dict[str, list[str]] = {
], ],
# Awaiting documentation - documenter claims or marks done # Awaiting documentation - documenter claims or marks done
"awaiting_documentation": ["claimed", "awaiting_pm_review", "cancelled"], "awaiting_documentation": ["claimed", "awaiting_pm_review", "cancelled"],
# Awaiting PM review - PM reviews and completes, or cancels # Awaiting PM review - PM claims to review, then completes or cancels
"awaiting_pm_review": ["completed", "cancelled"], "awaiting_pm_review": ["claimed", "completed", "cancelled"],
# Terminal states - cannot transition out # Terminal states - cannot transition out
"completed": [], "completed": [],
"cancelled": [], "cancelled": [],
@@ -73,6 +73,8 @@ ROLE_RESTRICTED_TRANSITIONS: dict[tuple[str, str], list[str]] = {
# Only documenter can claim docs tasks and mark complete # Only documenter can claim docs tasks and mark complete
("awaiting_documentation", "claimed"): ["documenter"], ("awaiting_documentation", "claimed"): ["documenter"],
("awaiting_documentation", "awaiting_pm_review"): ["documenter"], ("awaiting_documentation", "awaiting_pm_review"): ["documenter"],
# Only PM can claim PM review tasks
("awaiting_pm_review", "claimed"): _CANCEL_ROLES,
# Only PM can complete tasks (either after PM review or their own work) # Only PM can complete tasks (either after PM review or their own work)
("awaiting_pm_review", "completed"): _CANCEL_ROLES, ("awaiting_pm_review", "completed"): _CANCEL_ROLES,
("in_progress", "completed"): _CANCEL_ROLES, # PM completing their own task ("in_progress", "completed"): _CANCEL_ROLES, # PM completing their own task
+10 -13
View File
@@ -25,12 +25,15 @@ from mcp.server.fastmcp import FastMCP
from roboco.agents_config import ( from roboco.agents_config import (
NOTIFICATION_PERMISSIONS, NOTIFICATION_PERMISSIONS,
VALID_NOTIFICATION_PRIORITIES,
VALID_NOTIFICATION_TYPES,
can_send_notifications, can_send_notifications,
get_agent_cell, get_agent_cell,
get_agent_role, get_agent_role,
) )
from roboco.mcp.schemas import SendNotificationInput from roboco.mcp.schemas import SendNotificationInput
from roboco.mcp.utils import ApiClient, format_error_response from roboco.mcp.utils import ApiClient, format_error_response
from roboco.models.base import NotificationPriority, NotificationType
# ============================================================================= # =============================================================================
# HELPER FUNCTIONS # HELPER FUNCTIONS
@@ -82,13 +85,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
return can_send, reason return can_send, reason
# Valid notification types and priorities
VALID_NOTIFICATION_TYPES = frozenset(
["info", "alert", "task", "escalation", "approval"]
)
VALID_PRIORITIES = frozenset(["low", "normal", "high", "urgent"])
def _validate_notification_type(notification_type: str) -> dict[str, Any] | None: def _validate_notification_type(notification_type: str) -> dict[str, Any] | None:
"""Validate notification type. Returns error dict or None if valid.""" """Validate notification type. Returns error dict or None if valid."""
if notification_type not in VALID_NOTIFICATION_TYPES: if notification_type not in VALID_NOTIFICATION_TYPES:
@@ -101,10 +97,11 @@ def _validate_notification_type(notification_type: str) -> dict[str, Any] | None
def _validate_priority(priority: str) -> dict[str, Any] | None: def _validate_priority(priority: str) -> dict[str, Any] | None:
"""Validate priority. Returns error dict or None if valid.""" """Validate priority. Returns error dict or None if valid."""
if priority not in VALID_PRIORITIES: if priority not in VALID_NOTIFICATION_PRIORITIES:
valid = sorted(VALID_NOTIFICATION_PRIORITIES)
return format_error_response( return format_error_response(
"INVALID_PRIORITY", "INVALID_PRIORITY",
f"Invalid priority. Must be one of: {sorted(VALID_PRIORITIES)}", f"Invalid priority. Must be one of: {valid}",
) )
return None return None
@@ -392,8 +389,8 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
recipients=[escalate_to], recipients=[escalate_to],
subject=f"[ESCALATION] {subject}", subject=f"[ESCALATION] {subject}",
body=description, body=description,
notification_type="escalation", notification_type=NotificationType.BLOCKER_ESCALATION.value,
priority="high", priority=NotificationPriority.HIGH.value,
requires_ack=True, requires_ack=True,
related_task_id=task_id, related_task_id=task_id,
) )
@@ -416,8 +413,8 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP:
recipients=[approver], recipients=[approver],
subject=f"[APPROVAL NEEDED] {subject}", subject=f"[APPROVAL NEEDED] {subject}",
body=what_needs_approval, body=what_needs_approval,
notification_type="approval", notification_type=NotificationType.REVIEW_REQUEST.value,
priority="normal", priority=NotificationPriority.NORMAL.value,
requires_ack=True, requires_ack=True,
related_task_id=task_id, related_task_id=task_id,
) )
+16 -2
View File
@@ -551,7 +551,11 @@ def _register_pm_completion_tools(
"""Register PM-only task completion tools.""" """Register PM-only task completion tools."""
@mcp.tool() @mcp.tool()
async def roboco_task_complete(task_id: str) -> dict[str, Any]: async def roboco_task_complete(
task_id: str,
force_with_cancelled: bool = False,
justification: str | None = None,
) -> dict[str, Any]:
""" """
Mark task as completed (PM only). Mark task as completed (PM only).
@@ -561,14 +565,24 @@ def _register_pm_completion_tools(
ENFORCEMENT: ENFORCEMENT:
- Only PMs can use this tool - Only PMs can use this tool
- Task must be in 'awaiting_pm_review' status - Task must be in 'awaiting_pm_review' status
- All subtasks must be completed (or use force_with_cancelled)
PM Override for cancelled subtasks:
If some subtasks were cancelled but PM judges work is done anyway,
use force_with_cancelled=True with justification explaining why.
Only works if ALL non-completed subtasks are cancelled.
Args: Args:
task_id: The task UUID task_id: The task UUID
force_with_cancelled: Override cancelled subtask check
justification: Required when force_with_cancelled=True
Returns: Returns:
Completed task Completed task
""" """
return await handle_task_complete(client, task_id, agent_id) return await handle_task_complete(
client, task_id, agent_id, force_with_cancelled, justification
)
def _register_pm_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None: def _register_pm_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
+71 -34
View File
@@ -61,8 +61,13 @@ async def handle_docs_complete(
): ):
return error return error
payload = {"notes": doc_notes} if doc_notes else {} # Only send payload if doc_notes provided (QANotes.notes is required)
docs_resp = await client.post(f"/tasks/{task_id}/docs-complete", json=payload) if doc_notes:
docs_resp = await client.post(
f"/tasks/{task_id}/docs-complete", json={"notes": doc_notes}
)
else:
docs_resp = await client.post(f"/tasks/{task_id}/docs-complete")
if not docs_resp.ok: if not docs_resp.ok:
return format_error_response( return format_error_response(
@@ -98,26 +103,23 @@ async def _check_children_completed(
Returns error if any children are not completed, None if OK. Returns error if any children are not completed, None if OK.
""" """
try: try:
# Fetch children/subtasks for this task
resp = await client.get(f"/tasks/{task_id}/subtasks") resp = await client.get(f"/tasks/{task_id}/subtasks")
if not resp.ok: if not resp.ok:
# If endpoint doesn't exist or fails, skip check (backwards compat)
return None return None
subtasks = resp.json() subtasks = resp.json()
if not subtasks: if not subtasks:
return None # No children, OK to complete return None
incomplete: list[dict[str, str]] = [] incomplete = [
for subtask in subtasks: {
subtask_status = subtask.get("status") "id": str(subtask.get("id", "unknown")),
# ONLY "completed" is acceptable - cancelled/pending/etc. block completion "title": subtask.get("title", "Untitled"),
if subtask_status != "completed": "status": subtask.get("status") or "unknown",
incomplete.append({ }
"id": str(subtask.get("id", "unknown")), for subtask in subtasks
"title": subtask.get("title", "Untitled"), if subtask.get("status") != "completed"
"status": subtask_status or "unknown", ]
})
if incomplete: if incomplete:
return format_error_response( return format_error_response(
@@ -134,18 +136,59 @@ async def _check_children_completed(
return None return None
except Exception: except Exception:
# If check fails for any reason, allow completion (backwards compat)
return None return None
async def _validate_children_or_force(
client: ApiClient, task_id: str, force: bool, justification: str | None
) -> dict[str, Any] | None:
"""Validate children completion or force override. Returns error or None."""
if force:
if not justification:
return format_error_response(
"JUSTIFICATION_REQUIRED",
"force_with_cancelled requires justification explaining "
"why cancelled subtasks don't block completion.",
)
return None
return await _check_children_completed(client, task_id)
def _validate_completion_status(
task: dict[str, Any], agent_id: str
) -> dict[str, Any] | None:
"""Validate task is in completable status. Returns error or None."""
current_status = task.get("status")
is_own_task = current_status == "in_progress" and _is_pm_own_task(task, agent_id)
is_review_task = current_status == "awaiting_pm_review"
if is_own_task or is_review_task:
return None
return format_error_response(
"INVALID_STATE",
f"Cannot complete task in '{current_status}' status. "
"Expected 'awaiting_pm_review' (dev work) or 'in_progress' (own task).",
{"current_status": current_status},
)
async def handle_task_complete( async def handle_task_complete(
client: ApiClient, task_id: str, agent_id: str client: ApiClient,
task_id: str,
agent_id: str,
force_with_cancelled: bool = False,
justification: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle task completion (PM only). """Handle task completion (PM only).
Two completion paths: Two completion paths:
1. Completing developer work: task must be in 'awaiting_pm_review' 1. Completing developer work: task must be in 'awaiting_pm_review'
2. Completing PM's own task: task can be in 'in_progress' if assigned to PM 2. Completing PM's own task: task can be in 'in_progress' if assigned to PM
PM Override for cancelled subtasks:
Use force_with_cancelled=True with justification to complete despite
cancelled subtasks. Only works if ALL non-completed children are cancelled.
""" """
if error := _validate_pm_role(agent_id, "complete tasks"): if error := _validate_pm_role(agent_id, "complete tasks"):
return error return error
@@ -155,25 +198,19 @@ async def handle_task_complete(
return error return error
assert task is not None assert task is not None
current_status = task.get("status") if error := _validate_completion_status(task, agent_id):
# PM completing their own task - allow from in_progress
if current_status == "in_progress" and _is_pm_own_task(task, agent_id):
pass # Valid - PM completing their own work
# Normal path - developer work went through QA/Docs
elif current_status != "awaiting_pm_review":
return format_error_response(
"INVALID_STATE",
f"Cannot complete task in '{current_status}' status. "
"Expected 'awaiting_pm_review' (dev work) or 'in_progress' (own task).",
{"current_status": current_status},
)
# Check all children are completed before allowing parent completion
if error := await _check_children_completed(client, task_id):
return error return error
complete_resp = await client.post(f"/tasks/{task_id}/complete") if error := await _validate_children_or_force(
client, task_id, force_with_cancelled, justification
):
return error
payload: dict[str, Any] = {}
if force_with_cancelled:
payload = {"force_with_cancelled": True, "justification": justification}
complete_resp = await client.post(f"/tasks/{task_id}/complete", json=payload)
if not complete_resp.ok: if not complete_resp.ok:
return format_error_response( return format_error_response(
"COMPLETE_FAILED", "COMPLETE_FAILED",
+67
View File
@@ -77,6 +77,60 @@ def validate_assignee_can_work_on_team(
return None return None
def get_role_mismatch_warning(task: dict[str, Any], assignee: str) -> str | None:
"""Check if assignee role matches the task type and return warning if mismatch.
Returns a warning string or None if no mismatch.
This is a SOFT warning, not a blocking error - PM has flexibility.
"""
assignee_role = get_agent_role(assignee)
task_status = task.get("status")
task_title = task.get("title", "").lower()
# Role suggestions based on task status
status_role_map = {
"awaiting_qa": "qa",
"awaiting_documentation": "documenter",
"awaiting_pm_review": ("cell_pm", "main_pm"),
}
# Check status-based role matching
if task_status in status_role_map:
expected = status_role_map[task_status]
if isinstance(expected, tuple):
if assignee_role not in expected:
return (
f"Task is {task_status} - typically assigned to "
f"{' or '.join(expected)}, not {assignee_role}."
)
elif assignee_role != expected:
return (
f"Task is {task_status} - typically assigned to "
f"{expected}, not {assignee_role}."
)
# Check title-based hints for pending tasks
if task_status == "pending":
# QA-related keywords in title
qa_keywords = ["qa", "test", "validation", "quality", "review"]
if any(kw in task_title for kw in qa_keywords) and assignee_role != "qa":
return (
f"Task title suggests QA work but assignee is {assignee_role}. "
"Consider assigning to a QA agent."
)
# Docs-related keywords in title
doc_keywords = ["doc", "documentation", "readme", "guide", "reference"]
is_doc_task = any(kw in task_title for kw in doc_keywords)
if is_doc_task and assignee_role != "documenter":
return (
f"Task title suggests documentation but assignee is {assignee_role}. "
"Consider assigning to a documenter."
)
return None
def validate_cell_pm_assignment( def validate_cell_pm_assignment(
role: str, role: str,
agent_team: str | None, agent_team: str | None,
@@ -275,6 +329,13 @@ async def handle_task_create(
task = assigned_task task = assigned_task
guidance = _format_create_guidance(task, input_data.assigned_to) guidance = _format_create_guidance(task, input_data.assigned_to)
# Check for role mismatch and add warning if found
if input_data.assigned_to:
role_warning = get_role_mismatch_warning(task, input_data.assigned_to)
if role_warning:
guidance += f"\n\n⚠️ ROLE WARNING: {role_warning}"
return format_task_response(task, "CREATED", guidance) return format_task_response(task, "CREATED", guidance)
@@ -312,6 +373,12 @@ async def handle_task_assign(
f"Task assigned to {input_data.assignee} and set to pending. " f"Task assigned to {input_data.assignee} and set to pending. "
"Orchestrator will spawn them to claim and work on it." "Orchestrator will spawn them to claim and work on it."
) )
# Check for role mismatch and add warning if found
role_warning = get_role_mismatch_warning(task, input_data.assignee)
if role_warning:
guidance += f"\n\n⚠️ ROLE WARNING: {role_warning}"
return format_task_response(assigned_task, "ASSIGNED", guidance) return format_task_response(assigned_task, "ASSIGNED", guidance)
+8 -1
View File
@@ -21,11 +21,13 @@ from roboco.models import SubstituteReason, TaskStatus
HTTP_NOT_FOUND = 404 HTTP_NOT_FOUND = 404
# Map substitute reasons to target task statuses # Map substitute reasons to target task statuses
# NOTE: OUT_OF_SCOPE_ROLE goes to awaiting_pm_review to avoid reassignment loops
# (e.g., documenter can't self-document → substitute → gets reassigned same doc → loop)
REASON_TO_STATUS: dict[SubstituteReason, TaskStatus] = { REASON_TO_STATUS: dict[SubstituteReason, TaskStatus] = {
SubstituteReason.TASK_COMPLETE: TaskStatus.AWAITING_QA, SubstituteReason.TASK_COMPLETE: TaskStatus.AWAITING_QA,
SubstituteReason.LOW_CONTEXT: TaskStatus.PENDING, SubstituteReason.LOW_CONTEXT: TaskStatus.PENDING,
SubstituteReason.OUT_OF_SCOPE_TEAM: TaskStatus.PENDING, SubstituteReason.OUT_OF_SCOPE_TEAM: TaskStatus.PENDING,
SubstituteReason.OUT_OF_SCOPE_ROLE: TaskStatus.PENDING, SubstituteReason.OUT_OF_SCOPE_ROLE: TaskStatus.AWAITING_PM_REVIEW, # PM decides
SubstituteReason.MAX_RETRIES: TaskStatus.PENDING, SubstituteReason.MAX_RETRIES: TaskStatus.PENDING,
SubstituteReason.BLOCKED_EXTERNAL: TaskStatus.BLOCKED, SubstituteReason.BLOCKED_EXTERNAL: TaskStatus.BLOCKED,
} }
@@ -190,6 +192,11 @@ async def handle_task_substitute(
"Task marked as blocked. PM will be notified. " "Task marked as blocked. PM will be notified. "
"You are now free to claim new work with roboco_task_scan()." "You are now free to claim new work with roboco_task_scan()."
) )
elif substitute_reason == SubstituteReason.OUT_OF_SCOPE_ROLE:
next_action = (
"Task sent to PM for reassignment (role conflict). "
"You are now free to claim new work with roboco_task_scan()."
)
else: else:
next_action = ( next_action = (
"Task released and will be reassigned. " "Task released and will be reassigned. "
+2 -1
View File
@@ -91,7 +91,8 @@ def get_next_step_guidance(status: str) -> tuple[str, str]:
"needs_revision": ( "needs_revision": (
"FIX_ISSUES", "FIX_ISSUES",
"QA found issues. Read the QA notes carefully. " "QA found issues. Read the QA notes carefully. "
"Fix all issues, then re-submit for QA.", "Call roboco_task_start() to resume work, fix all issues, "
"then re-submit for QA with roboco_task_submit_qa().",
), ),
"awaiting_documentation": ( "awaiting_documentation": (
"DOCUMENT", "DOCUMENT",
+3
View File
@@ -30,6 +30,9 @@ class AuditEventType(str, Enum):
ACCESS_GRANTED = "access_granted" ACCESS_GRANTED = "access_granted"
ACCESS_REVOKED = "access_revoked" ACCESS_REVOKED = "access_revoked"
# PM override events
PM_OVERRIDE = "pm_override"
@dataclass @dataclass
class PermissionDenialContext: class PermissionDenialContext:
+16 -11
View File
@@ -4,33 +4,35 @@ Organization Models
Defines organizational structures: Cell, Board, Organization. Defines organizational structures: Cell, Board, Organization.
""" """
from typing import Any from typing import TYPE_CHECKING
from pydantic import BaseModel from pydantic import BaseModel
from roboco.models import Team from roboco.models import Team
if TYPE_CHECKING:
from roboco.agents.base import Agent
class Cell(BaseModel): class Cell(BaseModel):
"""A cell in the organization (backend, frontend, ux_ui).""" """A cell in the organization (backend, frontend, ux_ui)."""
name: str name: str
team: Team team: Team
pm: Any # Agent pm: "Agent"
developers: list[Any] = [] # List of Agent developers: list["Agent"] = []
qa: Any | None = None # Agent qa: "Agent | None" = None
documenter: Any | None = None # Agent documenter: "Agent | None" = None
model_config = {"arbitrary_types_allowed": True} model_config = {"arbitrary_types_allowed": True}
class Board(BaseModel): class Board(BaseModel):
"""The board of the organization.""" """The board of the organization (3 agents reporting to CEO)."""
product_owner: Any # Agent product_owner: "Agent"
head_marketing: Any # Agent head_marketing: "Agent"
auditor: Any # Agent auditor: "Agent"
main_pm: Any # Agent
model_config = {"arbitrary_types_allowed": True} model_config = {"arbitrary_types_allowed": True}
@@ -39,6 +41,9 @@ class Organization(BaseModel):
"""The complete organization structure.""" """The complete organization structure."""
board: Board board: Board
cells: dict[str, Cell] = {} main_pm: "Agent"
backend_cell: Cell
frontend_cell: Cell
ux_cell: Cell
model_config = {"arbitrary_types_allowed": True} model_config = {"arbitrary_types_allowed": True}
+20 -11
View File
@@ -270,6 +270,8 @@ class AgentOrchestrator:
"mcp__roboco-notify__*", "mcp__roboco-notify__*",
# Journal - always needed for reflection # Journal - always needed for reflection
"mcp__roboco-journal__*", "mcp__roboco-journal__*",
# Knowledge base/RAG - needed for research
"mcp__roboco-optimal__*",
# File operations for documenters and developers # File operations for documenters and developers
# Note: // prefix = absolute path (container paths like /app/docs) # Note: // prefix = absolute path (container paths like /app/docs)
"Write(//app/docs/**)", "Write(//app/docs/**)",
@@ -1696,11 +1698,12 @@ Start now: roboco_task_get("{task_id}")
to review and close the parent task. to review and close the parent task.
Monitors: tasks with completed subtasks but parent still open Monitors: tasks with completed subtasks but parent still open
Spawns: be-pm, fe-pm, ux-pm (based on parent team) Spawns: be-pm, fe-pm, ux-pm, main-pm (based on parent team)
""" """
# Find parent tasks that might have children ready for closure # Find parent tasks that might have children ready for closure
# Include "paused" - PM pauses while waiting, respawned when subtasks done # Include "paused" - PM pauses while waiting, respawned when subtasks done
parent_statuses = ["claimed", "in_progress", "paused"] # Include "awaiting_pm_review" - parent awaiting review when children done
parent_statuses = ["claimed", "in_progress", "paused", "awaiting_pm_review"]
for status in parent_statuses: for status in parent_statuses:
tasks = await self._fetch_tasks(client, status) tasks = await self._fetch_tasks(client, status)
@@ -1722,8 +1725,12 @@ Start now: roboco_task_get("{task_id}")
continue # Not ready for closure continue # Not ready for closure
# Parent has all subtasks completed - spawn PM to close # Parent has all subtasks completed - spawn PM to close
team = task.get("team", "backend") team = task.get("team")
pm_id = self._TEAM_PM_MAP.get(team, "be-pm") if team in ["backend", "frontend", "ux_ui"]:
pm_id = self._TEAM_PM_MAP.get(team, "be-pm")
else:
# main_pm, board, or no team → Main PM handles closure
pm_id = "main-pm"
if self._is_agent_active(pm_id): if self._is_agent_active(pm_id):
continue # PM already working continue # PM already working
@@ -1985,18 +1992,15 @@ Begin with step 1: roboco_task_get("{task_id}")
async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None: async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None:
""" """
Dispatch PM review work to cell PMs. Dispatch PM review work to cell PMs or Main PM.
Monitors: awaiting_pm_review tasks Monitors: awaiting_pm_review tasks
Spawns: be-pm, fe-pm, ux-pm Spawns: be-pm, fe-pm, ux-pm, main-pm
""" """
tasks = await self._fetch_tasks(client, "awaiting_pm_review") tasks = await self._fetch_tasks(client, "awaiting_pm_review")
for task in tasks: for task in tasks:
team = task.get("team") team = task.get("team")
if team not in ["backend", "frontend", "ux_ui"]:
continue
assigned_to = task.get("assigned_to") assigned_to = task.get("assigned_to")
# If already assigned, check if that agent is running # If already assigned, check if that agent is running
@@ -2012,8 +2016,13 @@ Begin with step 1: roboco_task_get("{task_id}")
) )
continue continue
# Unassigned task - select PM for this team # Unassigned task - select PM based on team
pm_id = self._TEAM_PM_MAP.get(team, "be-pm") # Cell tasks go to Cell PM, cross-cell/main_pm tasks go to Main PM
if team in ["backend", "frontend", "ux_ui"]:
pm_id = self._TEAM_PM_MAP.get(team, "be-pm")
else:
# main_pm, board, or no team → Main PM handles it
pm_id = "main-pm"
if self._is_agent_active(pm_id): if self._is_agent_active(pm_id):
continue continue
+24
View File
@@ -157,6 +157,30 @@ class AuditService(SingletonService):
timestamp=datetime.now(UTC).isoformat(), timestamp=datetime.now(UTC).isoformat(),
) )
async def log_pm_override(
self,
agent_id: str | UUID,
task_id: str | UUID,
action: str,
justification: str,
cancelled_subtask_ids: list[str] | None = None,
) -> None:
"""Log when a PM uses an override capability.
PM overrides are legitimate but need auditing - e.g., completing
a task despite cancelled subtasks when PM judges work is done.
"""
self.log.info(
"PM override used",
event_type=AuditEventType.PM_OVERRIDE.value,
agent_id=str(agent_id),
task_id=str(task_id),
action=action,
justification=justification,
cancelled_subtask_ids=cancelled_subtask_ids,
timestamp=datetime.now(UTC).isoformat(),
)
# ============================================================================= # =============================================================================
# SINGLETON INSTANCE # SINGLETON INSTANCE
+71 -3
View File
@@ -65,9 +65,18 @@ def _get_valid_claim_statuses(
if allow_reassign: if allow_reassign:
statuses.add(TaskStatus.CLAIMED) statuses.add(TaskStatus.CLAIMED)
return statuses return statuses
elif role in ("cell_pm", "main_pm"):
# PMs can claim:
# - PENDING: standard task claiming
# - AWAITING_PM_REVIEW: tasks submitted for PM approval
statuses = {TaskStatus.PENDING, TaskStatus.AWAITING_PM_REVIEW}
if allow_reassign:
statuses.add(TaskStatus.CLAIMED)
return statuses
else: else:
# Developer, PM, and other roles # Developer and other roles
statuses = {TaskStatus.PENDING} # NEEDS_REVISION for when task is reassigned after QA rejection
statuses = {TaskStatus.PENDING, TaskStatus.NEEDS_REVISION}
if allow_reassign: if allow_reassign:
statuses.add(TaskStatus.CLAIMED) statuses.add(TaskStatus.CLAIMED)
return statuses return statuses
@@ -406,6 +415,20 @@ class TaskService(BaseService):
return "agent not in task's team" return "agent not in task's team"
return None return None
def _validate_not_self_review(
self, task: TaskTable, agent: AgentTable | None, agent_id: UUID
) -> str | None:
"""Prevent QA/Documenter from claiming tasks they developed."""
if not agent or not agent.role:
return None
role = agent.role.value if hasattr(agent.role, "value") else str(agent.role)
if role not in ("qa", "documenter"):
return None
original_dev = extract_original_developer(task.quick_context)
if original_dev and original_dev == str(agent_id):
return "cannot review your own work (self-review)"
return None
def _set_original_developer_context( def _set_original_developer_context(
self, task: TaskTable, agent: AgentTable | None self, task: TaskTable, agent: AgentTable | None
) -> None: ) -> None:
@@ -453,7 +476,12 @@ class TaskService(BaseService):
self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id)) self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id))
return None return None
# Set context for QA/Documenter claims # Prevent self-review: QA/Documenter cannot claim tasks they developed
if error := self._validate_not_self_review(task, agent, agent_id):
self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id))
return None
# Set context for QA/Documenter claims (only if not already set)
self._set_original_developer_context(task, agent) self._set_original_developer_context(task, agent)
# Update assignment # Update assignment
@@ -466,6 +494,7 @@ class TaskService(BaseService):
TaskStatus.PENDING, TaskStatus.PENDING,
TaskStatus.AWAITING_QA, TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION, TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PM_REVIEW,
} }
if task.status in claimable_statuses: if task.status in claimable_statuses:
self._validate_and_set_status(task, TaskStatus.CLAIMED, agent_role) self._validate_and_set_status(task, TaskStatus.CLAIMED, agent_role)
@@ -922,6 +951,8 @@ class TaskService(BaseService):
self, self,
task_id: UUID, task_id: UUID,
agent_id: UUID | None = None, agent_id: UUID | None = None,
force_with_cancelled: bool = False,
justification: str | None = None,
) -> TaskTable | None: ) -> TaskTable | None:
""" """
Mark task as completed (PM only). Mark task as completed (PM only).
@@ -930,10 +961,16 @@ class TaskService(BaseService):
1. Developer work: task must be in AWAITING_PM_REVIEW (went through QA/Docs) 1. Developer work: task must be in AWAITING_PM_REVIEW (went through QA/Docs)
2. PM's own task: task can be IN_PROGRESS if assigned to the completing PM 2. PM's own task: task can be IN_PROGRESS if assigned to the completing PM
PM Override for cancelled subtasks:
Use force_with_cancelled=True with justification to complete despite
cancelled subtasks. Only works if ALL non-completed children are cancelled.
Args: Args:
task_id: The task to complete task_id: The task to complete
agent_id: Optional agent UUID - if provided, allows PM to complete agent_id: Optional agent UUID - if provided, allows PM to complete
their own in_progress tasks their own in_progress tasks
force_with_cancelled: Override cancelled subtask check
justification: Required when force_with_cancelled=True
Returns: Returns:
The completed task or None if completion not allowed The completed task or None if completion not allowed
@@ -961,6 +998,37 @@ class TaskService(BaseService):
) )
return None return None
# Check subtasks completion
subtasks = await self.get_subtasks(task_id)
incomplete_subtasks = [
st for st in subtasks if st.status != TaskStatus.COMPLETED
]
if incomplete_subtasks:
cancelled_only = all(
st.status == TaskStatus.CANCELLED for st in incomplete_subtasks
)
if force_with_cancelled and cancelled_only and justification:
# Log the PM override
self.log.info(
"PM override: completing task with cancelled subtasks",
task_id=str(task_id),
agent_id=str(agent_id) if agent_id else None,
justification=justification,
cancelled_subtask_ids=[str(st.id) for st in incomplete_subtasks],
)
else:
# Block completion
self.log.warning(
"Cannot complete task - incomplete subtasks exist",
task_id=str(task_id),
incomplete_count=len(incomplete_subtasks),
cancelled_only=cancelled_only,
force_requested=force_with_cancelled,
)
return None
task.completed_at = datetime.now(UTC) task.completed_at = datetime.now(UTC)
# Validate transition with PM role requirement # Validate transition with PM role requirement
self._validate_and_set_status(task, TaskStatus.COMPLETED, "cell_pm") self._validate_and_set_status(task, TaskStatus.COMPLETED, "cell_pm")