mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
It works pretty nicely with the ultimate test
This commit is contained in:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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. "
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user