Files
roboco/roboco/api/routes/tasks.py
T
Renn F 3efc96e402 fix(lifecycle): pr_pass hands ownership to the owning PM — closes the passed-PR completion wedge
Removing the awaiting_pm_review re-claim edge (d87e2d9b, the #740
review-loop fix) exposed that the edge was load-bearing: pr_pass
cleared assigned_to/claimed_by to None and the re-claim was the only
way a PM ever re-acquired the task. Since then every assembled task
passing the PR gate wedged: the closure PM's complete rejected with
'not assigned to you', its fallback claim rejected (edge gone), and
its only exit was escalate_up — BLOCKING the task onto main-pm, who
is not the assignee either and burned spawns doing nothing. Live:
PR #741's task (7 ownership rejections, escalated 13:59Z), plus two
more tasks with 25 and 2 rejections in the same shape.

pr_pass now resolves the owning PM via _revision_pm_for_task and
assigns it, exactly as pr_fail always did — one chokepoint covering
cell (submit_up) and root (submit_root) tasks. mark_pr_created's
ready_for_pm branch (leaf docs-path, PR-arrives-second) had the same
clear-to-None wedge and now resolves via _resolve_pm_for_review like
its docs-first sibling. No claim edge is reintroduced; the #740
parity test is untouched.

Recovery for already-wedged tasks needs no DB surgery: new idempotent
TaskService.assign_review_pm + POST /tasks/{id}/assign-review-pm
(ASSIGN-gated, explicit commit) corrects an unassigned OR mis-assigned
awaiting_pm_review task to its owning PM; _dispatch_pm_review_work
ensures assignment before every spawn (cheap pre-check skips the
round-trip when the fetched assigned_to already matches), replacing
the dead _claim_task_for_agent call whose lifecycle claim the removed
edge now always rejects; _maybe_spawn_pm_closure routes through
_closure_review_pm, which keeps the team-resolved PM whenever the
assign route fails so a transient error can never spawn a stale
assignee.

Gate: 15506 passed, 459 skipped; xenon/ruff/mypy/vulture/bandit/
pip-audit/deptry/import-linter/foundation-check green.
2026-07-31 18:48:45 +02:00

2807 lines
101 KiB
Python

"""
Task API Routes
Full CRUD operations and lifecycle management for tasks.
"""
from dataclasses import dataclass
from typing import Annotated, Any, cast
from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.api.deps import (
CurrentAgentContext,
DbSession,
PermissionServiceDep,
get_permission_service,
require_pm_or_above,
)
from roboco.api.schemas.tasks import (
BoardReviewEntry,
CancelTaskRequest,
CheckpointRequest,
ClaimRequest,
CollisionMapResponse,
CollisionSibling,
CommitRequest,
CompleteTaskRequest,
EscalateRequest,
EscalateResponse,
ProgressRequest,
QANotes,
SoftBlockRequest,
SubstituteRequest,
TaskCountResponse,
TaskFindingsResponse,
TaskResponse,
TaskSummaryResponse,
TaskUpdate,
TeamTasksQuery,
ValidTransitionsResponse,
enrich_task_with_context,
finding_to_response,
findings_summary,
task_list_to_response,
task_list_to_summary_response,
task_to_response,
transform_update_data,
)
from roboco.db.tables import TaskTable
from roboco.enforcement import get_valid_transitions
from roboco.exceptions import GitError, TaskLifecycleError
from roboco.foundation.policy import task_completeness as tc
from roboco.logging import get_logger
from roboco.models.base import AgentRole, TaskStatus, Team
from roboco.models.task import TaskCreate
from roboco.security import (
guard_deco,
prompt_injection_validator,
secret_exfil_validator,
)
from roboco.services.audit import get_audit_service
from roboco.services.base import (
NotFoundError,
ServiceError,
UnauthorizedError,
ValidationError,
)
from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.journal import get_journal_service
from roboco.services.notification_delivery import (
EscalationError,
get_notification_delivery_service,
)
from roboco.services.permissions import AgentContext, TaskAction
from roboco.services.repositories.review_findings import ReviewFindingsRepository
from roboco.services.task import (
SoftBlockInput,
TaskCreateRequest,
TaskService,
extract_original_developer,
get_task_service,
)
from roboco.utils.converters import require_uuid
router = APIRouter()
_logger = get_logger(__name__)
# #13: lifecycle-bypass hatch states — a privileged PATCH into one of these is a
# forced override that must carry the explicit ``force`` acknowledgement flag.
# The set covers every gate / terminal state a panel drag could paste a task
# into, bypassing the human gate that state represents: COMPLETED (the merge
# decision), AWAITING_QA / AWAITING_DOCUMENTATION / AWAITING_PR_REVIEW /
# AWAITING_PM_REVIEW / AWAITING_CEO_APPROVAL (the review/merge/CEO gates),
# and CANCELLED (the terminal cancel). Without force these are refused so the
# bypass is always an explicit, audited, acknowledged override — never a quiet
# panel click that drops a task into (or out of) a gate.
_HATCH_OVERRIDE_STATES = frozenset(
{
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
}
)
# Terminal statuses — a privileged PATCH OUT of one of these resurrects
# finished/cancelled work, which must also carry the explicit ``force``
# acknowledgement (mirrors the escalate route's refusal to resurrect).
_RESURRECT_SOURCE_STATES = frozenset({TaskStatus.COMPLETED, TaskStatus.CANCELLED})
@dataclass(frozen=True, slots=True)
class _StatusOverride:
"""Bundle of ``update_task`` override params (keeps the helper ≤ 5 args)."""
service: TaskService
task_id: UUID
task: TaskTable
new_status: TaskStatus
force: bool
has_higher_perms: bool
agent: AgentContext
async def _refuse_unforced_complete_with_open_pr(req: _StatusOverride) -> None:
"""Admin-complete must merge-or-refuse.
Completing a task whose PR is still OPEN strands its commits unmerged
(bit the CEO twice live, 2026-07-02). Checked before the generic hatch
text so the refusal names the PR and the consequence instead of a vague
gate message; ``force`` stays the deliberate, audited escape.
"""
if req.new_status != TaskStatus.COMPLETED or req.force:
return
open_ws = await req.service.open_pr_ref(req.task)
if open_ws is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Task still has OPEN PR #{open_ws.pr_number}"
f" ({open_ws.pr_url}); completing it now would strand"
" those commits unmerged. Merge the PR first (or approve"
" via POST /api/tasks/{id}/ceo-approve), or pass"
' "force": true to strand it deliberately.'
),
)
async def _apply_forced_status_override(req: _StatusOverride) -> TaskTable:
"""Apply an audited admin status override, gating the lifecycle bypass.
Extracted from ``update_task`` so the route's complexity stays readable.
Refuses a non-privileged caller, and refuses a bypass into a hatch state
without the explicit ``force`` flag; otherwise delegates to the audited
``admin_set_status`` and asserts the override landed.
"""
if req.new_status == req.task.status:
return req.task
if not req.has_higher_perms:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only privileged roles may override task status.",
)
await _refuse_unforced_complete_with_open_pr(req)
if req.new_status in _HATCH_OVERRIDE_STATES and not req.force:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Overriding a task into "
f"{req.new_status.value} bypasses the lifecycle gate; pass "
'"force": true to acknowledge the forced override.'
),
)
# Resurrecting a terminal task (completed / cancelled -> anything) is a
# bypass of the merge / cancel decision; it too requires the explicit force
# acknowledgement. The target-only hatch gate above misses this because the
# target (e.g. in_progress) is not itself a hatch state.
if req.task.status in _RESURRECT_SOURCE_STATES and not req.force:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Task is in the terminal state {req.task.status.value};"
" resurrecting it past the lifecycle gate requires"
' "force": true to acknowledge the override.'
),
)
task = await req.service.admin_set_status(
req.task_id,
req.new_status,
actor_id=req.agent.agent_id,
actor_role=getattr(req.agent, "role", None),
force=req.force,
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task status override failed unexpectedly",
)
return task
# Minimum character count for notes fields that must be substantive
# (QA pass notes, doc-complete notes, escalation notes). Below this the
# note is useless for the next reader, so the transition is refused.
_MIN_NOTES_CHARS = 20
# Nullable task fields that may be explicitly cleared via PATCH.
# After TaskService.update() gains its not-None guard, null-clears for these
# fields are handled at the route layer by direct setattr on the ORM object.
_NULLABLE_TASK_FIELDS: frozenset[str] = frozenset(
{"assigned_to", "parent_task_id", "project_id", "budget_usd"}
)
# Structural / ownership fields a bare task owner (UPDATE_OWN) must NOT
# self-edit — they reassign the task, move it between teams, re-parent the task
# tree, rewire the sequencing DAG, re-route it to another repo, or rewrite the
# delegation plan. These are PM/ASSIGN-gated operations; the verb layer gates
# them to PM roles (reassign/delegate/triage), so the REST PATCH surface must
# not let an owner bypass that by setattr-ing them directly. Only a caller with
# the higher ASSIGN permission may set them. budget_usd joins this set too — a
# self-serve budget raise on your own task would defeat the whole cap.
_PRIVILEGED_UPDATE_FIELDS: frozenset[str] = frozenset(
{
"assigned_to",
"team",
"parent_task_id",
"dependency_ids",
"blocker_ids",
"plan",
"project_id",
"budget_usd",
}
)
# The CEO's "PM lighter" scope: cell_pm/main_pm may PATCH this content-only
# slice — the same allowlist the Secretary's edit directive originally had
# (before it grew to Secretary FULL). No status changes, no structural/
# ownership fields (_PRIVILEGED_UPDATE_FIELDS), no git fields — those stay on
# the lifecycle-verb surface (delegate/reassign/complete/...).
_PM_LIGHTER_UPDATE_FIELDS: frozenset[str] = frozenset(
{"title", "description", "acceptance_criteria", "priority"}
)
# Roles that get the lighter slice above instead of the full ASSIGN-holding
# admin bypass. TaskAction.ASSIGN is not team-scoped (see
# can_perform_task_action), so a cell_pm would otherwise ride the same
# unrestricted bypass CEO/Board/Auditor get, on any team's task — the
# own-team restriction below is enforced independently of that permission.
_PM_LIGHTER_ROLES: frozenset[AgentRole] = frozenset(
{AgentRole.CELL_PM, AgentRole.MAIN_PM}
)
def _pm_editor_scope(
agent: AgentContext, task: TaskTable, *, has_higher_perms: bool
) -> bool:
"""Return True if ``agent`` gets the "PM lighter" content-only slice.
Raises 403 outright for a cell PM outside its own team — ASSIGN itself
is not team-scoped (see ``can_perform_task_action``), so without this
check a cross-team cell PM would fall through to the wider CEO/Board/
Auditor admin bypass on ``has_higher_perms`` alone.
"""
is_pm_editor = has_higher_perms and agent.role in _PM_LIGHTER_ROLES
if is_pm_editor and agent.role == AgentRole.CELL_PM and agent.team != task.team:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Cell PM may only update tasks belonging to their own team "
f"({agent.team}); this task is on {task.team}."
),
)
return is_pm_editor
def _enforce_pm_lighter_fields(
updates: dict[str, Any],
null_clears: dict[str, Any],
new_status: TaskStatus | None,
) -> None:
"""Refuse anything past the content-only allowlist for a PM-lighter editor.
"No status changes beyond what they already have" — status rides the
lifecycle verbs, never this PATCH surface, for cell_pm/main_pm.
"""
disallowed = (updates.keys() | null_clears.keys()) - _PM_LIGHTER_UPDATE_FIELDS
if not disallowed and new_status is None:
return
reasons = []
if disallowed:
reasons.append(f"disallowed fields {sorted(disallowed)}")
if new_status is not None:
reasons.append("status changes are not part of the PM PATCH surface")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"PM roles may only edit {sorted(_PM_LIGHTER_UPDATE_FIELDS)} via "
"PATCH; " + "; ".join(reasons)
),
)
def _translate_error(e: ServiceError) -> HTTPException:
"""Service errors → HTTP status. Kept at route layer; everything else moves."""
if isinstance(e, NotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.message)
if isinstance(e, UnauthorizedError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message)
if isinstance(e, ValidationError):
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=e.message)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.message
)
# ---------------------------------------------------------------------------
# Route-layer helpers — extracted to keep the three complex routes ≤ rank B.
# ---------------------------------------------------------------------------
def _task_is_awaiting_pm_review(task: Any) -> bool:
"""Return True if the task is in the awaiting_pm_review state."""
from roboco.models.base import TaskStatus as _TS
return (
task.status == _TS.AWAITING_PM_REVIEW
or getattr(task.status, "value", None) == "awaiting_pm_review"
)
def _pop_null_clears(updates: dict[str, Any]) -> dict[str, None]:
"""Remove and return explicitly-set-to-None nullable fields from *updates*.
TaskService.update() skips None values (not-None guard), so null-clearing
a field must be done at the route layer. This helper splits the intent:
it pops the null-clears from *updates* (modifying it in-place) and returns
them so the caller can apply them directly on the ORM object.
"""
clears: dict[str, None] = {}
for field in _NULLABLE_TASK_FIELDS:
if field in updates and updates[field] is None:
clears[field] = updates.pop(field)
return clears
def _apply_null_clears(task: Any, null_clears: dict[str, None]) -> None:
"""Set *null_clears* fields to None on the ORM task object.
Unassigning implies releasing the claim: a cleared assigned_to with a
surviving claimed_by/active_claimant_id keeps routing the task to the
stale claimant while the next agent's content writes bounce.
"""
for field in null_clears:
setattr(task, field, None)
if "assigned_to" in null_clears:
task.claimed_by = None
task.claimed_at = None
task.active_claimant_id = None
def _reassert_batch_shape(task: Any) -> None:
"""Raise HTTP 400 if a mutation broke the task's MegaTask shape. Raised
before any commit, so a violation rolls back cleanly."""
from roboco.services.task import TaskService
try:
TaskService.assert_batch_shape_intact(task)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
async def _resolve_assigned_to_slug(
data: "TaskUpdate", db: AsyncSession
) -> "TaskUpdate":
"""Resolve an assigned_to slug to a UUID string; returns (possibly modified) data.
If assigned_to was not set or is already a valid UUID or null, returns
*data* unchanged. If it is an agent slug, looks up the agent and replaces
the slug with the UUID string so downstream transform helpers parse it
correctly. Raises HTTPException 422 when the slug cannot be found.
"""
if "assigned_to" not in data.model_fields_set or data.assigned_to is None:
return data
try:
UUID(data.assigned_to)
return data # already a valid UUID — no resolution needed
except ValueError:
pass
from roboco.services.repositories.query_helpers import get_agent_by_slug
agent_row = await get_agent_by_slug(db, data.assigned_to)
if agent_row is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"error": {
"code": "ASSIGNEE_NOT_FOUND",
"message": f"No agent with slug or UUID '{data.assigned_to}'",
"hint": "Use an agent slug (e.g. 'be-dev-1') or UUID",
}
},
) from None
return data.model_copy(update={"assigned_to": str(agent_row.id)})
def _first_cell_map_project_id(task: Any) -> UUID | None:
"""First distinct project_id from a task's ad-hoc per-cell map.
Mirrors the product-root ``distinct_project_ids(...)[0]`` first-project
resolution: dedupes by project_id (a monorepo mapped across cells shares
one project), ordered by cell team for determinism. Returns None when the
task carries no cell map.
"""
cell_map = getattr(task, "cell_projects", None) or []
seen: set[UUID] = set()
for mapping in sorted(cell_map, key=lambda m: m.team.value):
pid = UUID(str(mapping.project_id))
if pid not in seen:
seen.add(pid)
return pid
return None
async def _project_for_complete(task: Any, db: AsyncSession) -> Any:
"""Resolve the project for complete_task's pre-merge step.
Returns the project or None if unresolvable (no exception raised — the
caller simply skips the merge when no project can be found).
"""
from roboco.services.project import get_project_service
project_service = get_project_service(db)
if task.project_id is not None:
return await project_service.get(UUID(str(task.project_id)))
if task.product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(db)
pids = await product_service.distinct_project_ids(UUID(str(task.product_id)))
if pids:
return await project_service.get(pids[0])
cell_pid = _first_cell_map_project_id(task)
if cell_pid is not None:
return await project_service.get(cell_pid)
return None
async def _merge_pr_if_awaiting_pm_review(
task_id: UUID,
pre_task: Any,
agent: Any,
db: AsyncSession,
) -> None:
"""Merge the task's PR when it is in awaiting_pm_review.
Does nothing when pre_task is None, has no PR, or is not in the right
state. Raises HTTPException 400 when the merge itself fails.
After this returns successfully, *_auto_complete_on_merge* inside the
git service will have already transitioned the task to *completed*.
"""
if pre_task is None or pre_task.pr_number is None:
return
if not _task_is_awaiting_pm_review(pre_task):
return
project = await _project_for_complete(pre_task, db)
if project is None:
return
from roboco.api.schemas.git import GitMergePRRequest
from roboco.services.git import get_git_service
git_service = get_git_service(db)
try:
await git_service.merge_pr_for_task(
agent.agent_id,
agent.role,
GitMergePRRequest(
project_slug=project.slug,
pr_number=pre_task.pr_number,
task_id=task_id,
merge_method="squash",
),
)
except (ServiceError, GitError) as e:
msg = getattr(e, "message", str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"PR merge failed before completion: {msg}",
) from e
async def _resolve_project_for_merge(task: Any, db: AsyncSession) -> Any:
"""Resolve and return the Project required for a merge operation.
Handles both direct project_id and product_id→project resolution.
Raises HTTPException 400 if no project can be resolved or found.
"""
from roboco.services.project import get_project_service
project_service = get_project_service(db)
if task.project_id is not None:
resolved_id = UUID(str(task.project_id))
elif task.product_id is not None:
from roboco.services.product import get_product_service
product_service = get_product_service(db)
project_ids = await product_service.distinct_project_ids(
UUID(str(task.product_id))
)
if not project_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"NO_PROJECT: Product {task.product_id} has no cell->project "
"mapping; cannot resolve workspace for merge."
),
)
resolved_id = project_ids[0]
else:
cell_pid = _first_cell_map_project_id(task)
if cell_pid is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PROJECT: Task has neither project_id, product_id, nor a "
"cell->project map; cannot resolve workspace for merge. Set a "
"target on the task first."
),
)
resolved_id = cell_pid
project = await project_service.get(resolved_id)
if not project:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"NO_PROJECT: Project {resolved_id} not found; "
"cannot resolve workspace for merge."
),
)
return project
# =============================================================================
# CRUD ENDPOINTS
# =============================================================================
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(prompt_injection_validator)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def create_task(
data: TaskCreate,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskResponse:
"""Create a new task."""
# Check create permission
if not permissions.can_perform_task_action(agent, TaskAction.CREATE, data.team):
# Log the denial. No task row exists yet, so record the attempted
# payload (title/team/type/project) under details with a distinct
# target_type — a "N/A" task_id would coerce to NULL and leave the
# denial unattributable, exactly where role-escalation attempts surface.
audit = get_audit_service()
await audit.log_task_creation_denial(
agent_id=agent.agent_id,
agent_role=agent.role.value,
action="create",
details={
"reason": "Role not permitted to create tasks",
"attempted_title": getattr(data, "title", None),
"attempted_team": getattr(getattr(data, "team", None), "value", None),
"attempted_task_type": getattr(
getattr(data, "task_type", None), "value", None
),
"attempted_project_id": str(getattr(data, "project_id", None) or ""),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to create tasks",
)
# `data.project_id` is `UUID` (required) on TaskCreate, so pydantic
# rejects missing/null values with 422 before this handler runs.
# Defense-in-depth completeness check. TaskCreate's Pydantic schema
# already enforces the structural rules in TASK_AT_CREATE (min_length
# on title/description/acceptance_criteria; the discriminator enums
# for task_type/nature/estimated_complexity/team are required). What
# Pydantic does NOT catch are the denylist phrases — placeholder ACs
# like "completed and reviewed by assignee" or stub descriptions —
# because those are well-formed strings. Re-running the canonical
# checker here catches them at the route boundary, so route, schema,
# and service all share one notion of "complete".
completeness = tc.check(tc.TASK_AT_CREATE, data)
if not completeness.passed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "incomplete_input",
"missing": completeness.missing,
"field_hints": completeness.field_hints,
},
)
# Resolve assigned_to: accept either a UUID string or an agent slug
# (e.g. "main-pm"). Slugs are how agents are addressed everywhere else
# in the tooling, so requiring a raw UUID here was a paper cut.
assigned_to_uuid: UUID | None = None
if data.assigned_to:
try:
assigned_to_uuid = UUID(data.assigned_to)
except ValueError:
from roboco.services.repositories.query_helpers import (
get_agent_by_slug,
)
agent_row = await get_agent_by_slug(db, data.assigned_to)
if agent_row is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"error": {
"code": "ASSIGNEE_NOT_FOUND",
"message": (
f"No agent with slug or UUID '{data.assigned_to}'"
),
"hint": "Use an agent slug (e.g. 'main-pm') or UUID",
}
},
) from None
assigned_to_uuid = cast("UUID", agent_row.id)
# Prompter origin tracking: enforce human confirmation gate so
# LLM-drafted tasks cannot bypass review and enter the workflow.
if data.source == "prompter" and not data.confirmed_by_human:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Prompter-originated tasks require human confirmation",
)
service = get_task_service(db)
req = TaskCreateRequest(
title=data.title,
description=data.description,
acceptance_criteria=data.acceptance_criteria,
team=data.team,
created_by=agent.agent_id,
priority=data.priority,
parent_task_id=data.parent_task_id,
assigned_to=assigned_to_uuid,
target_date=data.target_date,
estimated_complexity=data.estimated_complexity,
nature=data.nature,
status=data.status,
sequence=data.sequence, # Task ordering within siblings
dependency_ids=data.dependency_ids, # Dependencies for claim filtering
# Git configuration (all tasks follow git workflow)
task_type=data.task_type,
project_id=data.project_id,
product_id=data.product_id,
# Prompter origin tracking
source=data.source,
confirmed_by_human=data.confirmed_by_human,
)
task = await service.create(req)
await db.commit()
return task_to_response(task)
@router.get("", response_model=list[TaskResponse])
async def list_tasks(
db: DbSession,
agent: CurrentAgentContext,
team: Team | None = None,
status: TaskStatus | None = None,
limit: int = Query(100, ge=1, le=500),
) -> list[TaskResponse]:
"""
List tasks with optional filters.
View permissions:
- Main PM, Board, Auditor: Can see all tasks
- Cell PM: Can see own cell's tasks
- Cell members: Can only see own cell's tasks
"""
service = get_task_service(db)
permissions = get_permission_service()
# Determine effective team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team
if not can_view_all:
# Cell members can only see their own team's tasks
if agent.team:
effective_team = agent.team
else:
# No team assigned - return empty list
return []
if effective_team and status:
tasks = await service.list_by_team(effective_team, status, limit)
elif effective_team:
tasks = await service.list_by_team(effective_team, limit=limit)
elif status:
# list_by_status has no limit param — slice so the status-only
# branch can't return the whole table (it silently skipped the
# declared limit until 2026-07-02).
tasks = (await service.list_by_status(status))[:limit]
else:
tasks = await service.list_all(limit)
return task_list_to_response(tasks)
@router.get("/summary", response_model=list[TaskSummaryResponse])
async def list_tasks_summary(
*,
db: DbSession,
agent: CurrentAgentContext,
team: Team | None = None,
status: TaskStatus | None = None,
q: Annotated[str | None, Query(max_length=200)] = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
) -> list[TaskSummaryResponse]:
"""List tasks as trimmed summaries for panel list views.
Same filters and view permissions as the full list, ~50x lighter per
task: no description/plan/progress/commits/notes. The panel task tree
needs the whole set at once, so the default limit is higher than the
full route's. ``q`` searches title, description, and id prefix
server-side — summaries carry no description, so the search must
happen here, not in the browser.
"""
service = get_task_service(db)
permissions = get_permission_service()
effective_team = team
if not permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL):
if agent.team:
effective_team = agent.team
else:
return []
if q:
tasks = await service.search_tasks(
q, team=effective_team, status=status, limit=limit
)
return task_list_to_summary_response(tasks)
if effective_team and status:
tasks = await service.list_by_team(effective_team, status, limit)
elif effective_team:
tasks = await service.list_by_team(effective_team, limit=limit)
elif status:
tasks = (await service.list_by_status(status))[:limit]
else:
tasks = await service.list_all(limit)
return task_list_to_summary_response(tasks)
@router.get("/my", response_model=list[TaskResponse])
async def get_my_tasks(
db: DbSession,
agent: CurrentAgentContext,
status: TaskStatus | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> list[TaskResponse]:
"""Get tasks assigned to the current agent."""
service = get_task_service(db)
tasks = (await service.list_by_assignee(agent.agent_id, status))[:limit]
return task_list_to_response(tasks)
@router.get("/pending", response_model=list[TaskResponse])
async def get_pending_tasks(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
team: Team | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> list[TaskResponse]:
"""Get pending tasks available to claim."""
service = get_task_service(db)
# Apply team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team if can_view_all else agent.team
tasks = (await service.list_pending(effective_team))[:limit]
return task_list_to_response(tasks)
@router.get("/blocked", response_model=list[TaskResponse])
async def get_blocked_tasks(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
team: Team | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> list[TaskResponse]:
"""Get blocked tasks."""
service = get_task_service(db)
# Apply team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team if can_view_all else agent.team
tasks = (await service.list_blocked(effective_team))[:limit]
return task_list_to_response(tasks)
@router.get("/awaiting-qa", response_model=list[TaskResponse])
async def get_awaiting_qa_tasks(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
team: Team | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> list[TaskResponse]:
"""Get tasks awaiting QA review."""
service = get_task_service(db)
# Apply team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team if can_view_all else agent.team
tasks = (await service.list_awaiting_qa(effective_team))[:limit]
return task_list_to_response(tasks)
@router.get("/awaiting-docs", response_model=list[TaskResponse])
async def get_awaiting_docs_tasks(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
team: Team | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> list[TaskResponse]:
"""Get tasks awaiting documentation."""
service = get_task_service(db)
# Apply team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team if can_view_all else agent.team
tasks = (await service.list_awaiting_docs(effective_team))[:limit]
return task_list_to_response(tasks)
@router.get("/team/{team}", response_model=list[TaskResponse])
async def get_team_tasks(
team: Team,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
params: Annotated[TeamTasksQuery, Query()],
) -> list[TaskResponse]:
"""Get tasks for a specific team."""
# Check if agent can view this team's tasks
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
is_own_team = agent.team == team
if not can_view_all and not is_own_team:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this team's tasks",
)
service = get_task_service(db)
tasks = await service.list_by_team(team, params.task_status, params.limit)
return task_list_to_response(tasks)
@router.get("/stats", response_model=TaskCountResponse)
async def get_task_stats(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
team: Team | None = None,
) -> TaskCountResponse:
"""Get task counts by status."""
service = get_task_service(db)
# Apply team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team if can_view_all else agent.team
counts = await service.count_by_status(effective_team)
return TaskCountResponse(counts=counts)
@router.get("/stats/by-team", response_model=TaskCountResponse)
async def get_task_stats_by_team(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskCountResponse:
"""Get task counts by team."""
# Only agents with VIEW_ALL can see cross-team stats
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
if not can_view_all:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view cross-team statistics",
)
service = get_task_service(db)
counts = await service.count_by_team()
return TaskCountResponse(counts=counts)
# Static-segment routes must be declared BEFORE `/{task_id}` so FastAPI
# matches the literal path instead of treating the segment as a UUID
# (which would 422 on these names).
@router.get("/awaiting-pm-review", response_model=list[TaskResponse])
async def get_awaiting_pm_review_tasks(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
team: Team | None = None,
limit: Annotated[int, Query(ge=1, le=500)] = 200,
) -> list[TaskResponse]:
"""Get tasks awaiting PM review."""
service = get_task_service(db)
# Apply team filter based on permissions
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
effective_team = team if can_view_all else agent.team
tasks = (await service.list_awaiting_pm_review(effective_team))[:limit]
return task_list_to_response(tasks)
@router.get("/awaiting-ceo-approval", response_model=list[TaskResponse])
async def get_awaiting_ceo_approval_tasks(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> list[TaskResponse]:
"""Get tasks awaiting CEO approval.
CEO approval queue is org-wide (no team filter).
Only visible to PMs and above.
"""
# Only PMs and above can view the CEO approval queue
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
is_pm = agent.role in (AgentRole.CELL_PM, AgentRole.MAIN_PM)
is_ceo = agent.role == AgentRole.CEO
if not (can_view_all or is_pm or is_ceo):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs and management can view CEO approval queue",
)
service = get_task_service(db)
tasks = (await service.list_awaiting_ceo_approval())[:200]
return task_list_to_response(tasks)
@router.get("/external-pr-reviews", response_model=list[TaskResponse])
async def get_external_pr_reviews(
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> list[TaskResponse]:
"""Inbound external PRs the org is reviewing or has reviewed.
The PR-review queue: external-PR review tasks still in flight (the reviewer
is working) OR completed and awaiting the CEO's decision (not yet superseded
or dismissed). Active reviews surface so the panel shows a review underway
and links to the PR where the change-request is posted, instead of going
dark until it finishes. Org-wide; visible to PMs and above.
"""
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
is_pm = agent.role in (AgentRole.CELL_PM, AgentRole.MAIN_PM)
is_ceo = agent.role == AgentRole.CEO
if not (can_view_all or is_pm or is_ceo):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs and management can view the PR-review queue",
)
service = get_task_service(db)
tasks = (await service.list_external_pr_reviews())[:200]
return task_list_to_response(tasks)
@router.post("/{task_id}/supersede-external-pr")
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.block_clouds()
@guard_deco.usage_monitor(max_calls=30, window=3600)
async def supersede_external_pr(
task_id: UUID,
agent: CurrentAgentContext,
) -> dict[str, Any]:
"""CEO-authorized takeover of a reviewed external PR.
Confirms the review task and hands the contribution to the org: a
roboco-owned branch is cut from the contributor's fork head and a supersede
task is created for Main PM to delegate to a cell. This is the human
confirmation that authorizes fetching + running the contributor's code, so
it is CEO-only.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="only the CEO may supersede an external PR",
)
from roboco.api.deps import get_orchestrator
result = await get_orchestrator().supersede_external_pr(task_id)
if not result.get("ok"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(result.get("error", "supersede failed")),
)
return result
@router.post("/{task_id}/dismiss-external-pr")
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.block_clouds()
async def dismiss_external_pr(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> dict[str, Any]:
"""CEO declines to act on a reviewed external PR — drop it from the queue.
The review stays on the GitHub PR; this only records that the CEO chose not
to supersede, so the PR-review decision queue stops surfacing it. CEO-only.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="only the CEO may dismiss an external-PR review",
)
service = get_task_service(db)
task = await service.dismiss_external_pr_review(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="external-PR review task not found",
)
await db.commit()
return {"ok": True, "task_id": str(task_id)}
@router.get("/lifecycle-transitions", response_model=dict[str, list[str]])
async def get_lifecycle_transitions() -> dict[str, list[str]]:
"""Return the task lifecycle state graph as a JSON-serialisable dict.
Each key is a status name (string); each value is a list of valid next
status names (strings). The data is drawn directly from the canonical
``STATUS_GRAPH`` constant so it is always in sync with the enforcement
layer.
"""
from roboco.foundation.policy.lifecycle import STATUS_GRAPH
return {
src.value: sorted(tgt.value for tgt in targets)
for src, targets in STATUS_GRAPH.items()
}
@router.get("/{task_id}/valid-transitions", response_model=ValidTransitionsResponse)
async def get_valid_transitions_for_task(
task_id: UUID,
db: DbSession,
) -> ValidTransitionsResponse:
"""Return valid next statuses for a task given its current state.
Uses the canonical lifecycle enforcement layer so the response is always
in sync with what the backend will actually allow.
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
valid_statuses = get_valid_transitions(task.status)
return ValidTransitionsResponse(
valid_statuses=[TaskStatus(s) for s in valid_statuses]
)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: UUID,
db: DbSession,
) -> TaskResponse:
"""Get a specific task with full context (work session, project)."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
response = task_to_response(task)
# Enrich with work session and project context
response = await enrich_task_with_context(response, db)
# Gated the same as the budgets feature: an extra DB read, so only pay
# for it when the panel can actually make use of it (ROBOCO_TASK_BUDGETS_ENABLED).
from roboco.config import settings as _settings
if _settings.task_budgets_enabled:
response.spend_usd = await service.task_spend_usd(task_id)
return response
@router.put("/{task_id}", response_model=TaskResponse)
@router.patch("/{task_id}", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(prompt_injection_validator)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def update_task(
task_id: UUID,
data: TaskUpdate,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskResponse:
"""Update a task. Supports both PUT and PATCH for partial updates.
CEO and privileged roles can update any field including:
- Basic info (title, description, acceptance_criteria, priority, etc.)
- Ownership (team, assigned_to)
- Relationships (parent_task_id, dependency_ids, blocker_ids)
- Planning (plan with sub_tasks, risks, open_questions)
- Execution tracking (progress_updates, checkpoints)
- Artifacts (commits)
- Notes (dev_notes, qa_notes, auditor_notes, quick_context)
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Check if agent can update this task
# UPDATE_OWN requires agent to be assigned to or created the task
is_owner = agent.agent_id in {task.assigned_to, task.created_by}
can_update_own = permissions.can_perform_task_action(
agent, TaskAction.UPDATE_OWN, task.team
)
has_higher_perms = permissions.can_perform_task_action(
agent, TaskAction.ASSIGN, task.team
)
# PM roles (cell_pm/main_pm) ride the ASSIGN-holding bypass above like
# CEO/Board/Auditor, but get the narrower "PM lighter" content-only slice
# instead of unrestricted admin access (see _pm_editor_scope).
is_pm_editor = _pm_editor_scope(agent, task, has_higher_perms=has_higher_perms)
if not ((can_update_own and is_owner) or has_higher_perms):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this task",
)
# Resolve assigned_to slug → UUID (null is left for the null-clear path).
data = await _resolve_assigned_to_slug(data, db)
# Transform input data for database storage.
updates = transform_update_data(data)
# `status` is not a free-form field — it is an audited admin override so a
# privileged operator can recover a task wedged in a state with no valid
# in-band transition. Pop it out of the generic field update and apply it
# through the audited path, gated on elevated permissions.
new_status = updates.pop("status", None)
# #13: ``force`` is the explicit acknowledgement of the lifecycle bypass.
# Pop it so it is never passed to TaskService.update as a field set.
force = bool(updates.pop("force", False))
# Pop explicitly-set-to-None nullable fields. TaskService.update() skips
# None values (not-None guard), so null-clear intent is re-applied directly
# on the ORM object after the update returns.
null_clears = _pop_null_clears(updates)
# PM-lighter: restrict to the content-only allowlist, and refuse a status
# change outright — "no status changes beyond what they already have"
# (the lifecycle verbs), not a new capability riding this PATCH surface.
if is_pm_editor:
_enforce_pm_lighter_fields(updates, null_clears, new_status)
# A bare task owner (UPDATE_OWN) may edit dev-facing fields only. The
# structural / ownership fields are gated to ASSIGN/PM; an owner PATCHing
# any of them (set or explicitly nulled) without higher perms is refused —
# otherwise a dev self-reassigns / re-parents / re-routes their task past
# the verb layer's PM gate with no audited override.
touched_privileged = (
updates.keys() | null_clears.keys()
) & _PRIVILEGED_UPDATE_FIELDS
if touched_privileged and not has_higher_perms:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"Not authorized to set structural/ownership fields"
f" ({sorted(touched_privileged)}); reassign / re-parent /"
" re-route requires a PM role."
),
)
task = await service.update(task_id, **updates)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task update failed unexpectedly",
)
_apply_null_clears(task, null_clears)
# Null-clears apply AFTER service.update() (and its shape guard), so re-assert
# the MegaTask shape here too — a cleared parent_task_id / project_id must not
# turn a root-subtask into an umbrella-shaped-but-targeted spoof.
_reassert_batch_shape(task)
if new_status is not None:
task = await _apply_forced_status_override(
_StatusOverride(
service=service,
task_id=task_id,
task=task,
new_status=new_status,
force=force,
has_higher_perms=has_higher_perms,
agent=agent,
)
)
await db.commit()
return task_to_response(task)
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
@guard_deco.rate_limit(requests=20, window=60)
async def delete_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> None:
"""Delete a task."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only creators or agents with ASSIGN permission can delete tasks
is_creator = task.created_by == agent.agent_id
has_assign_perms = permissions.can_perform_task_action(
agent, TaskAction.ASSIGN, task.team
)
if not (is_creator or has_assign_perms):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this task",
)
await service.delete(task_id)
await db.commit()
@router.get("/{task_id}/subtasks", response_model=list[TaskResponse])
async def get_subtasks(
task_id: UUID,
db: DbSession,
) -> list[TaskResponse]:
"""Get subtasks of a task."""
service = get_task_service(db)
tasks = (await service.get_subtasks(task_id))[:500]
return task_list_to_response(tasks)
@router.get("/{task_id}/descendants", response_model=list[TaskResponse])
async def get_descendants(
task_id: UUID,
db: DbSession,
) -> list[TaskResponse]:
"""Get ALL descendants of a task (recursive - children, grandchildren, etc.)."""
service = get_task_service(db)
tasks = (await service.get_all_descendants(task_id))[:500]
return task_list_to_response(tasks)
@router.get("/{task_id}/board-review", response_model=list[BoardReviewEntry])
async def get_board_review(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> list[dict[str, Any]]:
"""Return the board's review of a task — the Product Owner + Head of
Marketing decision-log entries — so the CEO can read the actual analysis at
the approval/redraft gate instead of a placeholder. PM-or-above only.
Empty list when the board has not reviewed yet.
"""
require_pm_or_above(agent.role, "view the board review")
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return await get_journal_service(db).board_review_brief(task_id)
@router.get("/{task_id}/findings", response_model=TaskFindingsResponse)
async def get_task_findings(
task_id: UUID,
db: DbSession,
_agent: CurrentAgentContext,
) -> TaskFindingsResponse:
"""The revision-findings ledger for a task (qa_fail / pr_fail /
request_changes / ceo_reject), newest round first, plus per-origin
status counts. Read-only feed for the panel's Findings tab.
The list is capped (repository default); ``summary``/``total`` are SQL
aggregates over the whole ledger, with ``truncated`` flagging a capped
list — so the counts are never silently wrong for a big ledger.
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
repo = ReviewFindingsRepository(db)
rows = await repo.list_for_task(task_id)
counts = await repo.status_counts_for_task(task_id)
total = sum(count for _, _, count in counts)
return TaskFindingsResponse(
findings=[finding_to_response(r) for r in rows],
summary=findings_summary(counts),
total=total,
truncated=total > len(rows),
)
@router.get("/{task_id}/collision-map", response_model=CollisionMapResponse)
async def get_task_collision_map(
task_id: UUID,
db: DbSession,
_agent: CurrentAgentContext,
) -> CollisionMapResponse:
"""The reviewer/PM collision map for a task — its own declared surface
(``intends_to_touch`` / ``adds_migration`` / ``touches_shared``) plus
the surfaced siblings (same parent) that would collide with it: file
globs that overlap or a shared migration chain. Read-only feed for the
panel's Collision tab; the QA/PR-gate evidence envelopes carry the same
block inline (with declared-vs-actual drift, which needs the real
touched files the panel route doesn't resolve a workspace for).
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# No actual files here — the panel shows the declared surface + sibling
# overlap only; drift stays in the in-context evidence envelope.
# Best-effort: a fetch/build failure degrades to no siblings rather than
# a 500 — the route still returns the task's own declared surface.
ctx: list[dict[str, Any]] | None = None
try:
siblings = (
await service.get_subtasks(UUID(str(task.parent_task_id)))
if task.parent_task_id
else []
)
ctx = build_collision_context(task=task, siblings=siblings)
except Exception as exc:
_logger.warning(
"collision_map_route_skip", task_id=str(task.id), error=str(exc)
)
return CollisionMapResponse(
task_id=str(task.id),
parent_task_id=str(task.parent_task_id) if task.parent_task_id else None,
intends_to_touch=list(task.intends_to_touch or []),
adds_migration=bool(task.adds_migration),
touches_shared=bool(task.touches_shared),
siblings=[
CollisionSibling(
id=s["id"],
title=s.get("title"),
status=s.get("status", ""),
branch_name=s.get("branch_name"),
pr_number=s.get("pr_number"),
sequence=s.get("sequence"),
intends_to_touch=s.get("intends_to_touch", []),
adds_migration=s.get("adds_migration", False),
touches_shared=s.get("touches_shared", False),
overlap=s.get("overlap", []),
undeclared=s.get("undeclared", []),
)
for s in (ctx or [])
],
)
# =============================================================================
# LIFECYCLE ENDPOINTS
# =============================================================================
@router.post("/{task_id}/claim", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def claim_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
data: Annotated[ClaimRequest | None, Body()] = None,
) -> TaskResponse:
"""Claim a task (privileged roles may claim on behalf of another agent)."""
service = get_task_service(db)
try:
task = await service.claim_task_for_agent(
task_id,
agent,
permissions,
claim_target_slug=(data.agent_id if data else None),
)
except ServiceError as e:
raise _translate_error(e) from e
return task_to_response(task)
@router.post("/{task_id}/start", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def start_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Start working on a task."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can start the task
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can start this task",
)
# Field-level gates: must have a branch and (if claimed-first-time) a
# plan. The service checks plan internally but returns a generic None
# on failure; surface the specific cause here so the agent knows
# what to call next.
if not task.branch_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_BRANCH: Task has no branch assigned. Unclaim and "
"reclaim to regenerate the hierarchical branch, then "
"start."
),
)
if task.status.value == "claimed" and not task.plan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PLAN: Cannot start a claimed task without a plan. "
"Set task.plan via PATCH /api/tasks/{id} (panel) or "
"call gateway i_will_work_on(task_id, plan='...') (agents)."
),
)
# Pass agent_id and role for defense-in-depth validation in service layer
task = await service.start(task_id, agent_id=agent.agent_id, agent_role=agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Cannot start task - invalid status (must be claimed, "
"paused, or needs_revision)."
),
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/block", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def block_task(
task_id: UUID,
blocker_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Block a task due to a dependency."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent, PM, or the CEO can block a task
if task.assigned_to != agent.agent_id and agent.role not in (
AgentRole.CELL_PM,
AgentRole.MAIN_PM,
AgentRole.CEO,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to block this task",
)
task = await service.block(task_id, blocker_id)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task block failed unexpectedly",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/soft-block", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def soft_block_task(
task_id: UUID,
data: SoftBlockRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Soft-block a task due to an external factor (not a task dependency)."""
service = get_task_service(db)
try:
task = await service.soft_block_task_for_agent(
task_id,
agent,
SoftBlockInput(
blocker_type=data.blocker_type,
reason=data.reason,
what_needed=data.what_needed,
resolver_type=data.resolver_type,
),
)
except ServiceError as e:
raise _translate_error(e) from e
return task_to_response(task)
@router.post("/{task_id}/unblock", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def unblock_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Unblock a task and notify the assigned agent."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent, PM, or the CEO can unblock a task
if task.assigned_to != agent.agent_id and agent.role not in (
AgentRole.CELL_PM,
AgentRole.MAIN_PM,
AgentRole.CEO,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to unblock this task",
)
task = await service.unblock(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot unblock task - not blocked",
)
# TaskService.unblock() already sends the ALERT unblock notification
# (send_unblock_notification) to the restored owner + CEO — do not
# duplicate it here.
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/assign-review-pm", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def assign_review_pm(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskResponse:
"""Recovery seam: (re)assign an awaiting_pm_review task to its owning PM.
CLAIM_RULES deliberately excludes AWAITING_PM_REVIEW — no claim() edge
into it (the i_will_plan re-claim-loop fix) — so the orchestrator's
pm-review dispatchers call this instead of the claim route to place an
unassigned or stale-assigned review task with its real owner before
spawning it. Gated on the same ASSIGN permission that guards ownership
fields on the generic task PATCH.
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
if not permissions.can_perform_task_action(agent, TaskAction.ASSIGN, task.team):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to assign this task",
)
task = await service.assign_review_pm(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot assign review PM - task not awaiting_pm_review",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/pause", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def pause_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Pause a task."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only the assigned agent or the CEO can pause a task. The lifecycle
# spec's in_progress->paused transition carries no role restriction of
# its own (enforced upstream by the gateway's flow verbs, which never
# expose pause to agents at all) — this route is the sole gate, and the
# CEO carve-out here is deliberately narrower than unblock/block's
# (assignee-or-{CELL_PM, MAIN_PM, CEO}): pause has no PM-role carve-out.
if task.assigned_to != agent.agent_id and agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent or the CEO can pause this task",
)
task = await service.pause(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot pause task - not in progress",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/resume", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def resume_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Resume a paused task."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only the assigned agent or the CEO can resume a task — same carve-out
# as pause above, so a CEO who paused a task through the front door can
# also resume it through the front door.
if task.assigned_to != agent.agent_id and agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent or the CEO can resume this task",
)
task = await service.resume(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot resume task - not paused",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/verify", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def submit_for_verification(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Submit task for self-verification."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can submit for verification
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can submit for verification",
)
task = await service.submit_for_verification(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot verify task - not in progress",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/submit-qa", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def submit_for_qa(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Submit task for QA review."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can submit for QA
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can submit for QA",
)
# Field-level gates: dev must have committed, pushed, opened a PR,
# reported progress, and self-verified before QA can review. PR is
# REQUIRED at this stage — QA reviews on GitHub, not in a raw
# workspace diff. Without the pre-QA PR gate, the system falls into
# needless QA-fail → dev-creates-PR-in-revision cycles (pure token
# burn). A legitimate QA-fail (actual defect) is fine; a PR-missing
# fail is always avoidable.
if not task.self_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NOT_SELF_VERIFIED: Cannot submit for QA without a prior "
"self-verification step. Call gateway i_am_done() "
"(handles verification + QA submit), or for the panel "
"POST /api/tasks/{id}/verify before /submit-qa."
),
)
if not task.commits:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_COMMITS: Cannot submit for QA without at least one "
"commit on this task. Use the roboco-do `commit(message, "
"files)` verb before `i_am_done()` via gateway, or POST "
"/api/tasks/{id}/submit-qa."
),
)
if task.pr_number is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PR: Cannot submit for QA without a PR. The PR is "
"opened automatically by the choreographer when you call "
"`submit_for_qa(task_id)` (gateway flow verb) — make sure "
"you have at least one `commit(...)` on this task first "
"so the choreographer has something to push."
),
)
if not task.progress_updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PROGRESS: Cannot submit for QA without any "
"progress updates. Make at least one commit() during "
"execution — commit() auto-records a progress entry."
),
)
task = await service.submit_for_qa(task_id, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot submit for QA - not verifying",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/pass-qa", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def pass_qa(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: QANotes | None = None,
) -> TaskResponse:
"""Mark task as passed QA."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only QA agents can pass/fail QA
if agent.role != AgentRole.QA:
audit = get_audit_service()
await audit.log_task_action_denial(
agent_id=agent.agent_id,
agent_role=agent.role.value,
task_id=task_id,
action="pass_qa",
reason="Only QA agents can pass QA reviews",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only QA agents can pass QA reviews",
)
# QA cannot review their own tasks (prevent self-review)
# Check against original developer stored in quick_context, not current assigned_to
original_dev = extract_original_developer(task)
if original_dev and str(agent.agent_id) == original_dev:
audit = get_audit_service()
await audit.log_task_action_denial(
agent_id=agent.agent_id,
agent_role=agent.role.value,
task_id=task_id,
action="pass_qa",
reason="Self-review not permitted",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot QA review your own task",
)
# Defense-in-depth PR gate (submit_for_qa already blocks the no-PR
# case). If a task reaches awaiting_qa without a PR for any reason
# (legacy task, direct status manipulation), fail-qa with the note
# below is the right move — don't silently pass.
if task.pr_number is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PR_ATTACHED: Cannot pass QA without a PR on this "
"task. Call gateway fail(task_id, issues=['PR not created'])"
" or POST /api/tasks/{id}/fail-qa with the same issue, "
"so the dev fixes it."
),
)
# QA pass requires notes summarizing what was verified; without
# these, the dev can't learn from the review and the audit trail is
# empty.
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"QA_NOTES_REQUIRED: QA pass must include notes (>=20 "
"chars) summarizing what was verified against the "
"acceptance criteria. Call gateway pass(task_id, "
"notes='...') or POST /api/tasks/{id}/pass-qa with "
"notes set."
),
)
notes = data.notes
task = await service.pass_qa(task_id, notes, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot pass QA - invalid status for QA workflow",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/fail-qa", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def fail_qa(
task_id: UUID,
data: QANotes,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Mark task as failed QA."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only QA agents can pass/fail QA
if agent.role != AgentRole.QA:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only QA agents can fail QA reviews",
)
# QA cannot review their own tasks (prevent self-review)
# Check against original developer stored in quick_context, not current assigned_to
original_dev = extract_original_developer(task)
if original_dev and str(agent.agent_id) == original_dev:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot QA review your own task",
)
task = await service.fail_qa(task_id, data.notes, agent.role)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot fail QA - invalid status for QA workflow",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/docs-complete", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def docs_complete(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: QANotes | None = None,
) -> TaskResponse:
"""Mark documentation as complete (documenter only).
Transitions task from awaiting_documentation to awaiting_pm_review.
"""
# Audit: the documenter must record what was documented, so the next
# reader knows what exists. No note → empty trail, so reject.
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"DOC_NOTES_REQUIRED: docs-complete must include notes (>=20 "
"chars) describing what was documented and where."
),
)
service = get_task_service(db)
try:
task = await service.docs_complete_for_task(task_id, agent, notes=data.notes)
except ServiceError as e:
raise _translate_error(e) from e
return task_to_response(task)
@router.post("/{task_id}/submit-pm-review", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def submit_for_pm_review(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: QANotes | None = None,
) -> TaskResponse:
"""Submit a task directly for PM review.
Use this for tasks that don't follow the standard dev→QA→docs workflow,
such as PM validation tasks, QA audit tasks, or other directly-assigned work.
Only the assigned agent can submit their task for PM review.
"""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can submit for PM review
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can submit for PM review",
)
# Audit: the submitter must record what is ready for review.
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"PM_REVIEW_NOTES_REQUIRED: submit-pm-review must include notes "
"(>=20 chars) summarizing what is ready for the PM to review."
),
)
notes = data.notes
task = await service.submit_for_pm_review(task_id, agent.role.value, notes)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot submit for PM review - task not in progress",
)
delivery = get_notification_delivery_service(db)
await delivery.notify_pm_of_review_submission(
task=task,
task_id=task_id,
submitter_agent_id=agent.agent_id,
notes=notes,
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/complete", response_model=TaskResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def complete_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
data: Annotated[CompleteTaskRequest | None, Body()] = None,
) -> TaskResponse:
"""Mark task as completed (PM only).
Two completion paths:
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
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.
"""
# Audit: completing a task is a decision that must carry its rationale.
justification = data.justification if data else None
if not justification or len(justification.strip()) < _MIN_NOTES_CHARS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"COMPLETE_JUSTIFICATION_REQUIRED: complete must include a "
"justification (>=20 chars) recording why the task is done."
),
)
service = get_task_service(db)
# For tasks in awaiting_pm_review that still have an open PR, merge the PR
# first so the branch lands before the task is marked completed.
# _auto_complete_on_merge inside the git service will transition the task
# to completed automatically; re-fetch and detect that to avoid a
# double-completion error.
pre_task = await service.get(task_id)
await _merge_pr_if_awaiting_pm_review(task_id, pre_task, agent, db)
# Re-fetch: if the merge auto-completed the task, return without a second call.
merged_task = await service.get(task_id)
if merged_task and merged_task.status == TaskStatus.COMPLETED:
return task_to_response(merged_task)
try:
task = await service.complete_task_for_agent(
task_id,
agent,
permissions,
force_with_cancelled=(data.force_with_cancelled if data else False),
justification=justification,
)
except ServiceError as e:
raise _translate_error(e) from e
return task_to_response(task)
@router.post("/{task_id}/cancel", response_model=TaskResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def cancel_task(
task_id: UUID,
data: CancelTaskRequest,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskResponse:
"""Cancel a task. Reason is required for audit trail."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only PM or higher can cancel tasks
can_cancel = permissions.can_perform_task_action(
agent, TaskAction.CHANGE_PRIORITY, task.team
)
if not can_cancel:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to cancel tasks",
)
task = await service.cancel(
task_id,
agent_role=agent.role.value,
cancellation_note=f"[CANCELLED by {agent.role.value}] {data.reason}",
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task cancel failed unexpectedly",
)
await db.commit()
return task_to_response(task)
# =============================================================================
# CEO APPROVAL WORKFLOW
# =============================================================================
@router.post("/{task_id}/escalate-to-ceo", response_model=TaskResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.block_clouds()
async def escalate_to_ceo(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
data: QANotes | None = None,
) -> TaskResponse:
"""Escalate a task to CEO for final approval (PM only).
For major tasks that need CEO sign-off before merge: parent tasks
with subtasks, high-priority features, breaking changes.
"""
service = get_task_service(db)
try:
task = await service.escalate_to_ceo_for_agent(
task_id, agent, permissions, notes=(data.notes if data else None)
)
except ServiceError as e:
raise _translate_error(e) from e
return task_to_response(task)
@router.post("/{task_id}/ceo-approve", response_model=TaskResponse)
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.block_clouds()
@guard_deco.usage_monitor(max_calls=30, window=3600)
async def ceo_approve_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: QANotes | None = None,
) -> TaskResponse:
"""CEO approves and completes a task.
Final approval step for major tasks. Only CEO can perform this action.
"""
# Only CEO can approve
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can approve tasks in CEO approval queue",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# The CEO sign-off note is the audit record for merging to production —
# it must be present and substantive. An approval with no rationale leaves
# the audit trail empty, so reject it (the panel collects the note before
# POSTing). Order mirrors pass-qa: 404 before the notes gate.
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"CEO_NOTES_REQUIRED: CEO approval must include notes (>=20 "
"chars) recording why the work is approved for production. "
"POST /api/tasks/{id}/ceo-approve with notes='...'."
),
)
task = await service.ceo_approve(task_id, data.notes)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot approve - task not awaiting CEO approval",
)
await db.commit()
return task_to_response(task)
@router.get("/{task_id}/ceo-approve", response_model=TaskResponse)
async def ceo_approve_eligibility_check(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Pre-flight check: can this task be CEO-approved?
Returns the task if it is eligible (has a PR attached).
Returns HTTP 400 with 'NO_PR' if the task has no pull request.
Useful for panel gates and automated pre-checks before POSTing to
ceo-approve or approve-and-merge.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can check CEO-approval eligibility",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
if task.pr_number is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PR: Task has no pull request attached. A PR must be "
"opened and approved by QA before CEO approval. Use the "
"developer's open_pr flow to create the PR."
),
)
return task_to_response(task)
@router.post("/{task_id}/approve-and-merge", response_model=TaskResponse)
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.block_clouds()
@guard_deco.usage_monitor(max_calls=30, window=3600)
async def approve_and_merge_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""CEO merge + complete in one step.
Merges the task's PR, updates the work session, and marks the task
completed. Only CEO can perform this action. The PR must already exist
on the task (pr_number set). Merge failures are returned as structured
HTTP errors rather than unhandled exceptions.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can approve-and-merge tasks",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
if task.pr_number is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"NO_PR: Cannot approve-and-merge — task has no PR. "
"The developer must open a PR (open_pr gateway verb or "
"POST /api/git/create-pr) before CEO can merge."
),
)
# Resolve the project from the task's project_id / product_id.
project = await _resolve_project_for_merge(task, db)
from roboco.api.schemas.git import GitMergePRRequest
from roboco.services.git import get_git_service
git_service = get_git_service(db)
try:
await git_service.merge_pr_for_task(
agent.agent_id,
agent.role,
GitMergePRRequest(
project_slug=project.slug,
pr_number=task.pr_number,
task_id=task_id,
merge_method="squash",
),
)
except (ServiceError, GitError) as e:
msg = getattr(e, "message", str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Merge failed: {msg}",
) from e
except Exception as e:
_logger.exception(
"Unexpected error in approve-and-merge",
task_id=str(task_id),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Merge failed due to an unexpected error",
) from e
# merge_pr_for_task commits the session internally; re-fetch the
# updated task to return the merged state.
updated_task = await service.get(task_id)
if not updated_task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task disappeared after merge",
)
return task_to_response(updated_task)
@router.post("/{task_id}/approve-and-start", response_model=TaskResponse)
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.block_clouds()
@guard_deco.usage_monitor(max_calls=30, window=3600)
async def approve_and_start_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: QANotes | None = None,
) -> TaskResponse:
"""CEO gate #1: approve a board-reviewed task and hand it to Main PM.
Re-targets assigned_to -> main-pm while the task stays pending, so the
orchestrator spawns Main PM. Only CEO; requires substantive notes.
"""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can approve-and-start tasks",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Order mirrors ceo-approve: 404 before the notes gate.
if not data or not data.notes or len(data.notes.strip()) < _MIN_NOTES_CHARS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"START_NOTES_REQUIRED: approve-and-start must include notes "
"(>=20 chars) recording why the board work is ready to build. "
"POST /api/tasks/{id}/approve-and-start with notes='...'."
),
)
# A board task can't be started until the board has finished reviewing.
# The service enforces this too (defense in depth); the route surfaces a
# precise message rather than the generic "not startable".
if task.team == Team.BOARD and not task.board_review_complete:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"BOARD_REVIEW_INCOMPLETE: the Product Owner and Head of "
"Marketing must finish reviewing before this task can be "
"approved and started."
),
)
task = await service.approve_and_start(task_id, data.notes)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot start - task is not in a pending/startable state",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/ceo-reject", response_model=TaskResponse)
@guard_deco.rate_limit(requests=10, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.block_clouds()
async def ceo_reject_task(
task_id: UUID,
data: QANotes,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""CEO rejects a task and sends back for revision.
Task goes back to NEEDS_REVISION status. Notes are required.
"""
# Only CEO can reject
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only CEO can reject tasks in CEO approval queue",
)
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
task = await service.ceo_reject(task_id, data.notes)
if not task:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reject - task not awaiting CEO approval",
)
# Notify original developer if reassigned
if task.assigned_to:
delivery = get_notification_delivery_service(db)
await delivery.notify_assignee_of_ceo_rejection(
task=task,
task_id=task_id,
from_agent_id=agent.agent_id,
assignee_agent_id=require_uuid(task.assigned_to),
notes=data.notes,
)
await db.commit()
return task_to_response(task)
# =============================================================================
# ESCALATION (ALL AGENTS CAN ESCALATE)
# =============================================================================
@router.post("/{task_id}/escalate", response_model=EscalateResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
@guard_deco.suspicious_detection(enabled=True)
async def escalate_task(
task_id: UUID,
data: EscalateRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> EscalateResponse:
"""
Escalate a task to PM/management.
IMPORTANT: Unlike normal notifications, escalation is available to ALL agents.
This is a critical workflow tool for getting help when blocked.
Permission checks are intentionally bypassed for escalation.
Escalation chain:
- Developers → Cell PM
- QA → Cell PM
- Documenters → Cell PM
- Cell PM → Main PM
- Main PM → Product Owner
- Product Owner → CEO
"""
# Verify task exists
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# A terminal task (completed / cancelled) must not be resurrected to BLOCKED
# via escalation — refuse BEFORE sending the notification so a finished task
# isn't yanked back into the workflow. apply_escalation guards this too
# (defense in depth).
if task.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Task is in a terminal state ({task.status.value}) and cannot"
" be escalated — terminal tasks must not be resurrected."
),
)
delivery = get_notification_delivery_service(db)
try:
outcome = await delivery.escalate_and_notify(
task=task,
task_id=task_id,
escalator_agent_id=agent.agent_id,
reason=data.reason,
explicit_target_slug=data.escalate_to,
)
except EscalationError as e:
# Preserve the pre-refactor status-code mapping exactly:
# - missing escalator agent -> 404 (agent lookup failure)
# - override rejected -> 403 (chain violation)
# - no chain / target missing -> 400 (validation / config)
detail = str(e)
if detail.startswith("escalator agent"):
http_code = status.HTTP_404_NOT_FOUND
elif "Cannot escalate to" in detail:
http_code = status.HTTP_403_FORBIDDEN
else:
http_code = status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=http_code, detail=detail) from e
# BLOCKED (not PENDING) prevents the orchestrator from respawning the
# original dev until the PM unblocks. Task state mutations live in
# TaskService.apply_escalation — routes never touch task fields directly.
await service.apply_escalation(
task=task,
target_agent_id=outcome.target_agent_id,
escalator_slug=outcome.escalator_slug,
target_slug=outcome.target_slug,
reason=data.reason,
)
await db.commit()
msg = (
f"Task escalated to {outcome.target_slug} and set to BLOCKED. "
f"PM will receive notification and must call gateway unblock(task_id) "
"to provide guidance or reassign."
)
return EscalateResponse(
status="escalated",
task_id=task_id,
escalated_to=outcome.target_slug,
reason=data.reason,
message=msg,
)
# =============================================================================
# SUBSTITUTION (ALL AGENTS CAN SUBSTITUTE OUT)
# =============================================================================
@router.post("/{task_id}/substitute", response_model=TaskResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def substitute_task(
task_id: UUID,
data: SubstituteRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Request to be substituted out of a task — graceful release.
Bypasses the "can't claim while in_progress" rule. Reasons:
`low_context`, `out_of_scope_team`, `out_of_scope_role`, `task_complete`,
`max_retries`, `blocked_external`.
"""
service = get_task_service(db)
try:
task = await service.substitute_task_for_agent(
task_id, agent, reason_raw=data.reason, details=data.details
)
except ServiceError as e:
raise _translate_error(e) from e
return task_to_response(task)
# =============================================================================
# PROGRESS AND ARTIFACTS
# =============================================================================
@router.post("/{task_id}/progress", response_model=TaskResponse)
@guard_deco.rate_limit(requests=60, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def add_progress(
task_id: UUID,
data: ProgressRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Add a progress update to a task."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can add progress
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can add progress updates",
)
task = await service.add_progress(
task_id, agent.agent_id, data.message, data.percentage
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Add progress failed unexpectedly",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/checkpoint", response_model=TaskResponse)
@guard_deco.rate_limit(requests=60, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def add_checkpoint(
task_id: UUID,
data: CheckpointRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Add a checkpoint for state recovery."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can add checkpoints
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can add checkpoints",
)
task = await service.add_checkpoint(
task_id,
agent.agent_id,
data.state_summary,
data.remaining_work,
data.notes,
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Add checkpoint failed unexpectedly",
)
await db.commit()
return task_to_response(task)
@router.post("/{task_id}/commit", response_model=TaskResponse)
@guard_deco.rate_limit(requests=60, window=60)
@guard_deco.max_request_size(size_bytes=65536)
@guard_deco.custom_validation(secret_exfil_validator)
@guard_deco.content_type_filter(["application/json"])
async def add_commit(
task_id: UUID,
data: CommitRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskResponse:
"""Link a commit to a task."""
service = get_task_service(db)
task = await service.get(task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
# Only assigned agent can link commits
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the assigned agent can link commits",
)
task = await service.add_commit(task_id, data.hash, data.message, agent.agent_id)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Add commit failed unexpectedly",
)
await db.commit()
return task_to_response(task)
# =============================================================================
# TASK ACTIVATION (PM ONLY)
# =============================================================================
@router.post("/{task_id}/activate", response_model=TaskResponse)
@guard_deco.rate_limit(requests=30, window=60)
async def activate_task(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
permissions: PermissionServiceDep,
) -> TaskResponse:
"""
Activate a task from BACKLOG to PENDING status (PM only).
This is the final step in PM setup. After creating a session and
linking the task, the PM activates it to make it ready for work.
REQUIRES: Task must have at least one linked session.
"""
# Check PM permission (CREATE permission required for activation)
if not permissions.can_perform_task_action(agent, TaskAction.CREATE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only PMs and management can activate tasks",
)
service = get_task_service(db)
try:
task = await service.activate(task_id, agent.role)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except TaskLifecycleError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
) from e
await db.commit()
return task_to_response(task)