Files
roboco/roboco/services/task.py
T

2812 lines
96 KiB
Python

"""
Task Service
Provides CRUD operations and lifecycle management for tasks.
Handles status transitions, assignments, and queries.
"""
import asyncio
from datetime import UTC, datetime
from typing import Any, ClassVar, cast
from uuid import UUID
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import (
AgentTable,
ProjectTable,
SessionTaskTable,
TaskTable,
WorkSessionTable,
)
from roboco.enforcement import (
TaskOwnershipError,
validate_task_ownership,
validate_task_transition,
)
from roboco.events import Event, EventType, get_event_bus
from roboco.models.base import AgentRole, TaskStatus, Team
from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionStatus
from roboco.services.base import BaseService
from roboco.utils.converters import require_uuid, to_python_uuid
# UUID format constants for validation
_UUID_LENGTH = 36 # Standard UUID string length
_UUID_HYPHEN_COUNT = 4 # Number of hyphens in a UUID
def _get_valid_claim_statuses(
agent: AgentTable | None,
allow_reassign: bool,
) -> set[TaskStatus]:
"""
Get valid task statuses an agent can claim based on their role.
Role-based claiming:
- QA: can only claim AWAITING_QA tasks
- Documenter: can only claim AWAITING_DOCUMENTATION tasks
- Developers/PMs: can claim PENDING (and CLAIMED if allow_reassign)
Args:
agent: The agent attempting to claim
allow_reassign: Whether to allow claiming already-claimed tasks
Returns:
Set of valid TaskStatus values the agent can claim
"""
if not agent or not agent.role:
# No role info - default to pending tasks only
statuses = {TaskStatus.PENDING}
if allow_reassign:
statuses.add(TaskStatus.CLAIMED)
return statuses
role = agent.role.value if hasattr(agent.role, "value") else str(agent.role)
if role == "qa":
# QA can claim:
# - PENDING: when PM assigns a QA task directly
# - AWAITING_QA: normal workflow after dev verification
statuses = {TaskStatus.PENDING, TaskStatus.AWAITING_QA}
if allow_reassign:
statuses.add(TaskStatus.CLAIMED)
return statuses
elif role == "documenter":
# Documenters can claim:
# - PENDING: when PM assigns a docs task directly
# - AWAITING_DOCUMENTATION: normal workflow after QA passes
statuses = {TaskStatus.PENDING, TaskStatus.AWAITING_DOCUMENTATION}
if allow_reassign:
statuses.add(TaskStatus.CLAIMED)
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:
# Developer and other roles
# NEEDS_REVISION for when task is reassigned after QA rejection
statuses = {TaskStatus.PENDING, TaskStatus.NEEDS_REVISION}
if allow_reassign:
statuses.add(TaskStatus.CLAIMED)
return statuses
def extract_original_developer(quick_context: str | None) -> str | None:
"""
Safely extract original developer ID from quick_context.
The quick_context stores original developer as: "original_developer:{uuid}"
This is used to prevent self-review (QA reviewing their own work).
Args:
quick_context: The task's quick_context field value
Returns:
UUID string of original developer, or None if not found/invalid
"""
if not quick_context:
return None
prefix = "original_developer:"
if not quick_context.startswith(prefix):
return None
try:
dev_id = quick_context[len(prefix) :].strip()
# Validate it looks like a UUID
if len(dev_id) == _UUID_LENGTH and dev_id.count("-") == _UUID_HYPHEN_COUNT:
return dev_id
return None
except Exception:
return None
class TaskService(BaseService):
"""
Service for managing tasks.
Provides:
- CRUD operations
- Status transitions with validation
- Assignment and claiming
- Queries by team, status, assignee
- Dependency management
"""
service_name: ClassVar[str] = "task"
_background_tasks: ClassVar[set[asyncio.Task[None]]] = set()
# =========================================================================
# STATUS TRANSITION HELPER
# =========================================================================
def _validate_and_set_status(
self,
task: TaskTable,
new_status: TaskStatus,
agent_role: str | None = None,
) -> None:
"""
Validate and set task status with lifecycle enforcement.
This is the single point of truth for status changes. All transitions
are validated against VALID_TRANSITIONS and ROLE_RESTRICTED_TRANSITIONS.
Args:
task: The task to update
new_status: Target status
agent_role: Optional role for role-restricted transitions
Raises:
TaskLifecycleError: If transition is invalid or role not permitted
"""
current = (
task.status.value if isinstance(task.status, TaskStatus) else task.status
)
target = new_status.value if isinstance(new_status, TaskStatus) else new_status
# Validate the transition (raises TaskLifecycleError if invalid)
validate_task_transition(current, target, agent_role)
# Apply the status change
task.status = new_status
self.log.info(
"Task status transition",
task_id=str(task.id),
from_status=current,
to_status=target,
agent_role=agent_role,
)
# =========================================================================
# CRUD OPERATIONS
# =========================================================================
async def create(self, req: TaskCreateRequest) -> TaskTable:
"""
Create a new task.
Default status is PENDING. PM can pass status=BACKLOG when creating
subtasks that need session setup before activation.
"""
task = TaskTable(
title=req.title,
description=req.description,
acceptance_criteria=req.acceptance_criteria,
team=req.team,
created_by=req.created_by,
assigned_to=req.assigned_to,
priority=req.priority,
parent_task_id=req.parent_task_id,
target_date=req.target_date,
estimated_complexity=req.estimated_complexity,
nature=req.nature,
status=req.status if req.status else TaskStatus.PENDING,
sequence=req.sequence, # Task ordering within siblings
dependency_ids=req.dependency_ids, # Task IDs that must complete first
)
self.session.add(task)
await self.session.flush()
# Inherit parent task's primary session for subtasks
if req.parent_task_id:
await self._inherit_parent_session(
task_id=cast("UUID", task.id),
parent_task_id=req.parent_task_id,
created_by=req.created_by,
)
self.log.info(
"Task created",
task_id=str(task.id),
title=req.title,
team=req.team if isinstance(req.team, str) else req.team.value,
)
return task
async def _inherit_parent_session(
self,
task_id: UUID,
parent_task_id: UUID,
created_by: UUID,
) -> SessionTaskTable | None:
"""
Inherit the parent task's primary session for a subtask.
When a subtask is created, it automatically joins the parent task's
primary discussion session (if one exists). This enables context
continuity across the task hierarchy.
Args:
task_id: The new subtask ID
parent_task_id: The parent task ID
created_by: PM who created the subtask
Returns:
Created link if parent had a primary session, None otherwise
"""
# Find parent's primary session
result = await self.session.execute(
select(SessionTaskTable).where(
SessionTaskTable.task_id == parent_task_id,
SessionTaskTable.is_primary.is_(True),
)
)
parent_link = result.scalar_one_or_none()
if not parent_link:
return None
# Create link for subtask (not primary - parent owns the primary)
link = SessionTaskTable(
session_id=parent_link.session_id,
task_id=task_id,
is_primary=False, # Subtasks don't become primary
relationship_type=parent_link.relationship_type,
added_by=created_by,
)
self.session.add(link)
await self.session.flush()
self.log.info(
"Subtask inherited parent session",
task_id=str(task_id),
parent_task_id=str(parent_task_id),
session_id=str(parent_link.session_id),
)
return link
async def activate(self, task_id: UUID) -> TaskTable:
"""
Activate a task from BACKLOG to PENDING status.
This is a PM-only operation that transitions a task from setup
phase to ready-for-work phase. The orchestrator will then spawn
agents to work on it.
REQUIRES: Task must have at least one linked session.
Args:
task_id: The task to activate
Returns:
The activated task
Raises:
ValueError: If task not found, not in BACKLOG, or has no session
"""
task = await self.get(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status != TaskStatus.BACKLOG:
raise ValueError(
f"Task {task_id} is not in BACKLOG status (current: {task.status})"
)
# Check if task has at least one linked session
result = await self.session.execute(
select(SessionTaskTable).where(SessionTaskTable.task_id == task_id).limit(1)
)
session_link = result.scalar_one_or_none()
if not session_link:
raise ValueError(
f"Task {task_id} has no linked session. "
"Create a session with roboco_session_create_for_tasks "
"before activating."
)
# Transition to PENDING
task.status = TaskStatus.PENDING
await self.session.flush()
self.log.info(
"Task activated",
task_id=str(task_id),
session_id=str(session_link.session_id),
)
return task
async def get(self, task_id: UUID) -> TaskTable | None:
"""Get a task by ID."""
result = await self.session.execute(
select(TaskTable).where(TaskTable.id == task_id)
)
return result.scalar_one_or_none()
async def update(
self,
task_id: UUID,
**updates: Any,
) -> TaskTable | None:
"""Update a task."""
task = await self.get(task_id)
if not task:
return None
for key, value in updates.items():
if hasattr(task, key) and value is not None:
setattr(task, key, value)
await self.session.flush()
self.log.info(
"Task updated",
task_id=str(task_id),
updates=list(updates.keys()),
)
return task
async def delete(self, task_id: UUID) -> bool:
"""Delete a task and all its descendants."""
task = await self.get(task_id)
if not task:
return False
# Delete all descendants first (children, grandchildren, etc.)
# Process in reverse order to delete leaves before parents
descendants = await self.get_all_descendants(task_id)
descendants.reverse() # Delete deepest children first
for descendant in descendants:
await self.session.delete(descendant)
if descendants:
self.log.info(
"Cascaded delete to descendants",
task_id=str(task_id),
deleted_count=len(descendants),
)
await self.session.delete(task)
await self.session.flush()
self.log.info("Task deleted", task_id=str(task_id))
return True
# =========================================================================
# STATUS TRANSITIONS
# =========================================================================
def _validate_claim_status(
self,
task: TaskTable,
agent: AgentTable | None,
valid_statuses: set[TaskStatus],
) -> str | None:
"""
Validate task status for claiming.
Returns:
Error message if invalid, None if valid
"""
role_specific = {TaskStatus.AWAITING_QA, TaskStatus.AWAITING_DOCUMENTATION}
if not agent and task.status in role_specific:
return "role required for this status"
if task.status not in valid_statuses:
return "invalid status for role"
return None
# Management roles that can claim tasks from any team
_MANAGEMENT_ROLES = frozenset(
{"main_pm", "product_owner", "head_marketing", "auditor"}
)
def _validate_claim_team(
self, task: TaskTable, agent: AgentTable | None
) -> str | None:
"""
Validate agent belongs to task's team.
Management roles (main_pm, product_owner, head_marketing, auditor)
can claim tasks from any team.
Returns:
Error message if invalid, None if valid
"""
if not agent or not task.team:
return None
# Get agent role as string
agent_role = (
agent.role.value if hasattr(agent.role, "value") else str(agent.role)
)
# Management roles can claim any task
if agent_role in self._MANAGEMENT_ROLES:
return None
# Regular agents must match team
if agent.team != task.team:
return "agent not in task's team"
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(
self, task: TaskTable, agent: AgentTable | None
) -> None:
"""Set original developer context for QA/Documenter claims.
Important: We only store original_developer if it's a DIFFERENT agent.
If PM assigned directly to QA/Documenter (no prior developer), we don't
set original_developer to avoid blocking them with self-review check.
"""
if not agent or not agent.role:
return
role = agent.role.value if hasattr(agent.role, "value") else str(agent.role)
if role not in ("qa", "documenter"):
return
existing_context = task.quick_context or ""
if "original_developer:" in existing_context:
return
# Only set original_developer if it's a DIFFERENT agent than the one claiming
# This prevents blocking QA/Documenter when PM assigns directly to them
if task.assigned_to and str(task.assigned_to) != str(agent.id):
task.quick_context = f"original_developer:{task.assigned_to}"
async def claim(
self, task_id: UUID, agent_id: UUID, allow_reassign: bool = False
) -> TaskTable | None:
"""
Claim a task for an agent.
Role-based claiming:
- Developers/PMs: can claim PENDING tasks
- QA: can claim AWAITING_QA tasks
- Documenters: can claim PENDING (direct assignment) or AWAITING_DOCUMENTATION
"""
task = await self.get(task_id)
if not task:
return None
# Get agent for role-based validation
agent_result = await self.session.execute(
select(AgentTable).where(AgentTable.id == agent_id)
)
agent = agent_result.scalar_one_or_none()
valid_statuses = _get_valid_claim_statuses(agent, allow_reassign)
# Validate status
if error := self._validate_claim_status(task, agent, valid_statuses):
self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id))
return None
# Validate team
if error := self._validate_claim_team(task, agent):
self.log.warning(f"Cannot claim task - {error}", task_id=str(task_id))
return None
# 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)
# Update assignment
task.assigned_to = cast("Any", agent_id)
task.claimed_at = datetime.now(UTC)
# Transition to CLAIMED - validated with role for proper enforcement
agent_role = agent.role.value if agent and agent.role else None
claimable_statuses = {
TaskStatus.PENDING,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PM_REVIEW,
}
if task.status in claimable_statuses:
self._validate_and_set_status(task, TaskStatus.CLAIMED, agent_role)
await self.session.flush()
# Create work session for git-enabled tasks claimed by developers
await self._create_work_session_if_needed(task, agent_id, agent_role)
# Trigger proactive knowledge injection (fire-and-forget)
bg_task = asyncio.create_task(self._inject_proactive_context(task, agent_id))
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def _inject_proactive_context(self, task: TaskTable, agent_id: UUID) -> None:
"""Inject proactive knowledge context when task is claimed.
Runs as a background task, so uses its own database session.
"""
from uuid import UUID as PyUUID
from roboco.db.base import get_session_factory
from roboco.services.proactive import get_proactive_service
task_id = PyUUID(str(task.id))
task_title = task.title
task_description = task.description or ""
try:
proactive = await get_proactive_service()
agent_uuid = PyUUID(str(agent_id))
context = await proactive.on_task_claimed(
task_id=task_id,
agent_id=agent_uuid,
task_title=task_title,
task_description=task_description,
task_type=None,
)
if context and not context.is_empty():
# Store context in the task using a fresh session
session_factory = get_session_factory()
async with session_factory() as session:
from sqlalchemy import update
await session.execute(
update(TaskTable)
.where(TaskTable.id == task_id)
.values(proactive_context=context.to_dict())
)
await session.commit()
self.log.info(
"Stored proactive context",
task_id=str(task_id),
items=len(context.similar_tasks)
+ len(context.relevant_learnings)
+ len(context.code_patterns),
)
except Exception as e:
# Don't fail - this is fire-and-forget
self.log.warning(
"Failed to inject proactive context",
task_id=str(task_id),
error=str(e),
)
# =========================================================================
# GIT WORK SESSION INTEGRATION
# =========================================================================
async def _create_work_session_if_needed(
self,
task: TaskTable,
agent_id: UUID,
agent_role: str | None,
) -> WorkSessionTable | None:
"""
Create a WorkSession when a developer claims a git-enabled task.
Only creates a session if:
- Task requires git (requires_git=True)
- Task has a project_id set
- Task has a branch_name set (PM created the branch)
- Agent is a developer (not QA/Documenter claiming for review)
Args:
task: The task being claimed
agent_id: Agent claiming the task
agent_role: Agent's role
Returns:
Created WorkSession or None if not applicable
"""
# Only developers need work sessions (not QA/Documenter claiming)
if agent_role not in ("developer", None):
return None
# Check if task requires git workflow
if not getattr(task, "requires_git", True):
return None
# Need project and branch to create a session
project_id = getattr(task, "project_id", None)
branch_name = getattr(task, "branch_name", None)
if not project_id or not branch_name:
self.log.debug(
"Skipping work session - no project or branch",
task_id=str(task.id),
has_project=bool(project_id),
has_branch=bool(branch_name),
)
return None
# Get project to determine base/target branches
result = await self.session.execute(
select(ProjectTable).where(ProjectTable.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
self.log.warning(
"Project not found for work session",
task_id=str(task.id),
project_id=str(project_id),
)
return None
# Check if session already exists for this task+agent
existing = await self.session.execute(
select(WorkSessionTable).where(
and_(
WorkSessionTable.task_id == task.id,
WorkSessionTable.agent_id == agent_id,
WorkSessionTable.status == WorkSessionStatus.ACTIVE,
)
)
)
if existing.scalar_one_or_none():
self.log.debug(
"Work session already exists",
task_id=str(task.id),
agent_id=str(agent_id),
)
return None
# Determine target branch:
# - For subtasks: merge into parent task's branch
# - For parent tasks: merge into default branch (main)
default_branch = project.default_branch
target_branch: str = str(default_branch) if default_branch else "main"
if task.parent_task_id:
# Get parent task's branch
parent_id = cast("UUID", task.parent_task_id)
parent = await self.get(parent_id)
parent_branch = getattr(parent, "branch_name", None) if parent else None
if parent_branch:
target_branch = str(parent_branch)
# Create the work session
work_session = WorkSessionTable(
project_id=project_id,
task_id=task.id,
agent_id=agent_id,
branch_name=branch_name,
base_branch=target_branch, # Created from target
target_branch=target_branch, # Will merge back to target
status=WorkSessionStatus.ACTIVE,
)
self.session.add(work_session)
await self.session.flush()
# Link session to task
task.work_session_id = cast("Any", work_session.id)
await self.session.flush()
self.log.info(
"Work session created for task",
task_id=str(task.id),
session_id=str(work_session.id),
branch=branch_name,
target=target_branch,
)
return work_session
# =========================================================================
# RAG AUTO-INDEXING HOOKS (Fire-and-forget background tasks)
# =========================================================================
# Learning extraction thresholds
_DURATION_OVER_RATIO = 1.5 # Flag if task took 1.5x expected time
_DURATION_UNDER_RATIO = 0.3 # Flag if task took less than 30% expected
_MIN_COMMITS_GOOD = 5 # Minimum commits for "good granularity" pattern
_MIN_NOTES_LENGTH = 50 # Minimum notes length to extract learnings
async def _extract_completion_learnings(
self, task: TaskTable, agent_id: UUID | None
) -> None:
"""Extract and record learnings from a completed task (fire-and-forget)."""
from roboco.services.learning import (
LearningType,
RecordLearningParams,
get_learning_service,
)
# Extract data before session detaches
task_id = task.id
task_title = task.title
task_team = task.team.value if task.team else None
started_at = task.started_at
completed_at = task.completed_at
estimated_complexity = task.estimated_complexity
commits = list(task.commits) if task.commits else []
dev_notes = task.dev_notes
qa_notes = task.qa_notes
assigned_to = task.assigned_to
try:
learning_svc = await get_learning_service()
learnings: list[tuple[str, LearningType]] = []
# Determine scope based on team
scope = self._determine_learning_scope(task_team)
# 1. Duration vs estimate insight
if started_at and completed_at:
duration_hours = (completed_at - started_at).total_seconds() / 3600
complexity_hours = {"low": 2.0, "medium": 8.0, "high": 24.0}
complexity_val = (
estimated_complexity.value
if hasattr(estimated_complexity, "value")
else str(estimated_complexity)
)
expected = complexity_hours.get(complexity_val, 8.0)
ratio = duration_hours / expected if expected > 0 else 1.0
if ratio > self._DURATION_OVER_RATIO:
msg = (
f"Task '{task_title}' ({complexity_val}) took "
f"{duration_hours:.1f}h vs expected {expected:.0f}h."
)
learnings.append((msg, LearningType.INSIGHT))
elif ratio < self._DURATION_UNDER_RATIO:
msg = (
f"Task '{task_title}' ({complexity_val}) completed "
f"quickly in {duration_hours:.1f}h."
)
learnings.append((msg, LearningType.INSIGHT))
# 2. Commit pattern analysis
if len(commits) >= self._MIN_COMMITS_GOOD:
msg = f"Good commit granularity on '{task_title}': {len(commits)}."
learnings.append((msg, LearningType.PATTERN))
elif len(commits) == 1:
learnings.append(
(
f"Single commit on '{task_title}'. Try smaller increments.",
LearningType.GOTCHA,
)
)
# 3. Extract from dev_notes
if dev_notes and len(dev_notes) > self._MIN_NOTES_LENGTH:
learnings.append(
(
f"[DEV NOTES] {task_title}: {dev_notes[:500]}",
LearningType.SOLUTION,
)
)
# 4. Extract from qa_notes
if qa_notes and len(qa_notes) > self._MIN_NOTES_LENGTH:
learnings.append(
(
f"[QA FEEDBACK] {task_title}: {qa_notes[:500]}",
LearningType.REVIEW_FEEDBACK,
)
)
# Record all learnings
for content, ltype in learnings:
await learning_svc.record_learning(
RecordLearningParams(
agent_id=to_python_uuid(assigned_to) or agent_id or UUID(int=0),
agent_role="developer",
content=content,
learning_type=ltype,
scope=scope,
task_id=to_python_uuid(task_id),
tags=["auto-extracted", task_team or "general"],
)
)
if learnings:
self.log.info(
"Extracted completion learnings",
task_id=str(task_id),
count=len(learnings),
)
except Exception as e:
self.log.warning(
"Failed to extract learnings",
task_id=str(task_id),
error=str(e),
)
def _determine_learning_scope(self, team: str | None) -> Any:
"""Map team to learning scope."""
from roboco.services.learning import LearningScope
if team in ("backend", "frontend", "ux_ui"):
return LearningScope.CELL
if team in ("board", "main_pm"):
return LearningScope.ORG
return LearningScope.TEAM
async def _index_code_changes_background(
self, task_id: UUID, commits: list[dict[str, Any]], project: str
) -> None:
"""Index code files from task commits (fire-and-forget)."""
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
# Extract unique file paths from commits
files: set[str] = set()
for commit in commits:
commit_files = commit.get("files", [])
if isinstance(commit_files, list):
files.update(str(f) for f in commit_files)
if files:
count = await optimal.index_code(list(files), project=project)
self.log.debug(
"Indexed code files",
task_id=str(task_id),
files_count=count,
)
except Exception as e:
self.log.warning(
"Failed to index code",
task_id=str(task_id),
error=str(e),
)
def _extract_decisions_from_notes(
self, notes: str, task_title: str
) -> list[dict[str, str]]:
"""Parse notes for decision patterns."""
decisions = []
decision_patterns = [
"decided to",
"chose",
"decision:",
"went with",
"selected",
"opted for",
"rationale:",
"instead of",
]
notes_lower = notes.lower()
for pattern in decision_patterns:
if pattern in notes_lower:
lines = notes.split(".")
for line in lines:
if pattern in line.lower():
decisions.append(
{
"topic": task_title,
"decision": line.strip()[:300],
"rationale": "Auto-extracted from task notes",
}
)
break
return decisions
async def _index_decisions_background(
self,
task_id: UUID,
task_title: str,
task_team: Team | None,
dev_notes: str | None,
agent_id: UUID | None,
) -> None:
"""Index decisions detected in notes (fire-and-forget)."""
from roboco.models.optimal import IndexDecisionParams
from roboco.services.optimal import get_optimal_service
if not dev_notes:
return
try:
optimal = await get_optimal_service()
decisions = self._extract_decisions_from_notes(dev_notes, task_title)
for decision in decisions:
await optimal.index_decision(
IndexDecisionParams(
topic=decision["topic"],
decision=decision["decision"],
rationale=decision["rationale"],
agent_id=agent_id,
task_id=task_id,
scope="team",
tags=[task_team.value if task_team else "general", "auto"],
)
)
if decisions:
self.log.debug(
"Indexed decisions",
task_id=str(task_id),
count=len(decisions),
)
except Exception as e:
self.log.warning(
"Failed to index decisions",
task_id=str(task_id),
error=str(e),
)
async def _index_docs_background(
self, task_id: UUID, documents: list[dict[str, Any]]
) -> None:
"""Index documentation from completed doc task (fire-and-forget)."""
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
# Extract doc paths from documents array
doc_paths: list[str] = [
str(d.get("path")) for d in documents if d.get("path")
]
if doc_paths:
count = await optimal.index_documentation(doc_paths, project="roboco")
self.log.debug(
"Indexed docs",
task_id=str(task_id),
docs_count=count,
)
except Exception as e:
self.log.warning(
"Failed to index docs",
task_id=str(task_id),
error=str(e),
)
# =========================================================================
# QA AND ERROR INDEXING HOOKS
# =========================================================================
def _parse_qa_notes(self, qa_notes: str) -> list[dict[str, str]]:
"""Parse QA notes into structured issues."""
issues = []
for raw_line in qa_notes.split("\n"):
stripped = raw_line.strip()
if stripped.startswith(("-", "*", "•")):
issues.append(
{
"severity": "error",
"description": stripped.lstrip("-*• "),
}
)
elif stripped and stripped[0].isdigit() and "." in stripped[:3]:
parts = stripped.split(".", 1)
desc = parts[1].strip() if len(parts) > 1 else stripped
issues.append({"severity": "error", "description": desc})
if not issues and qa_notes.strip():
issues.append({"severity": "error", "description": qa_notes[:500]})
return issues
async def _index_qa_review_background(
self,
task_id: UUID,
quick_context: str | None,
passed: bool,
qa_notes: str,
qa_agent_id: UUID | None,
) -> None:
"""Index QA review (fire-and-forget)."""
from roboco.models.optimal import IndexReviewParams
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
original_dev = extract_original_developer(quick_context)
await optimal.record_review(
IndexReviewParams(
file_path=f"task/{task_id}",
comments=[
{
"body": qa_notes,
"type": "qa",
"severity": "info" if passed else "error",
}
],
approved=passed,
summary=qa_notes[:500] if qa_notes else "QA Review",
reviewer_id=qa_agent_id,
author_id=UUID(original_dev) if original_dev else None,
task_id=task_id,
)
)
self.log.debug("Indexed QA review", task_id=str(task_id), passed=passed)
except Exception as e:
self.log.warning(
"Failed to index QA review",
task_id=str(task_id),
error=str(e),
)
async def _index_qa_errors_background(
self,
task_id: UUID,
task_title: str,
task_team: Team | None,
qa_notes: str,
) -> None:
"""Index QA failure issues as error patterns (fire-and-forget)."""
from roboco.models.optimal import IndexErrorParams
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
issues = self._parse_qa_notes(qa_notes)
for issue in issues:
await optimal.index_error(
IndexErrorParams(
error_message=f"QA Failure: {issue['description'][:200]}",
context=f"Task: {task_title}",
solution="",
worked=False,
task_id=task_id,
team=task_team.value if task_team else None,
tags=["qa_failure", issue["severity"]],
)
)
self.log.debug(
"Indexed QA errors",
task_id=str(task_id),
count=len(issues),
)
except Exception as e:
self.log.warning(
"Failed to index QA errors",
task_id=str(task_id),
error=str(e),
)
async def _index_blocker_background(
self,
task_id: UUID,
task_team: Team | None,
blocker_info: dict[str, str],
) -> None:
"""Index blocker as error pattern (fire-and-forget).
Args:
task_id: Task UUID
task_team: Team for categorization
blocker_info: Dict with keys: type, title, reason, what_needed
"""
from roboco.models.optimal import IndexErrorParams
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
blocker_type = blocker_info.get("type", "unknown")
reason = blocker_info.get("reason", "")
title = blocker_info.get("title", "")
what_needed = blocker_info.get("what_needed", "")
await optimal.index_error(
IndexErrorParams(
error_message=f"Blocker ({blocker_type}): {reason[:200]}",
context=f"Task: {title}\nNeeded: {what_needed}",
solution="",
worked=False,
task_id=task_id,
team=task_team.value if task_team else None,
tags=["blocker", blocker_type.lower()],
)
)
self.log.debug("Indexed blocker", task_id=str(task_id))
except Exception as e:
self.log.warning(
"Failed to index blocker",
task_id=str(task_id),
error=str(e),
)
async def _index_lifecycle_event_background(
self,
task_id: UUID,
event_type: str,
task_title: str,
task_team: Team | None,
details: dict[str, Any] | None = None,
) -> None:
"""Index lifecycle event for pattern analysis (fire-and-forget).
Tracks task state transitions for organizational learning:
- Cancellation patterns (what gets cancelled and why)
- Pause/resume patterns (context switching costs)
- Block/unblock patterns (dependency bottlenecks)
Args:
task_id: Task UUID
event_type: One of: cancel, pause, resume, block, unblock
task_title: Task title for context
task_team: Team for categorization
details: Additional event details
"""
from roboco.models.optimal import IndexJournalEntryParams
from roboco.services.optimal import get_optimal_service
try:
optimal = await get_optimal_service()
details = details or {}
# Build content for indexing
content = f"[{event_type.upper()}] {task_title}"
if details:
content += f"\nDetails: {details}"
# Index to journals for lifecycle tracking
await optimal.index_journal_entry(
IndexJournalEntryParams(
content=content,
entry_id=None, # Will be auto-generated
agent_id=None, # System event, no specific agent
entry_type=f"lifecycle_{event_type}",
task_id=task_id,
tags=[event_type, task_team.value if task_team else "default"],
)
)
self.log.debug(
"Indexed lifecycle event",
task_id=str(task_id),
event_type=event_type,
)
except Exception as e:
self.log.warning(
"Failed to index lifecycle event",
task_id=str(task_id),
event_type=event_type,
error=str(e),
)
async def start(
self, task_id: UUID, agent_id: UUID | None = None
) -> TaskTable | None:
"""
Start working on a task.
Args:
task_id: The task to start
agent_id: Optional agent ID to validate ownership
Returns:
The started task, or None if not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Validate ownership if agent_id provided
if agent_id is not None:
try:
assigned = task.assigned_to
validate_task_ownership(
agent_id=str(agent_id),
task_id=str(task_id),
task_assigned_to=str(assigned) if assigned else None,
task_team=task.team.value if task.team else "backend",
action="start",
)
except TaskOwnershipError as e:
self.log.warning(
"Cannot start task - ownership validation failed",
task_id=str(task_id),
agent_id=str(agent_id),
error=str(e),
)
return None
# Valid statuses to start/resume work:
# - CLAIMED: Developer just claimed a pending task
# - PAUSED: Developer resuming paused work
# - NEEDS_REVISION: Developer resuming after QA rejection
valid_start_statuses = (
TaskStatus.CLAIMED,
TaskStatus.PAUSED,
TaskStatus.NEEDS_REVISION,
)
if task.status not in valid_start_statuses:
self.log.warning(
"Cannot start task - invalid status",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# PLAN required before starting from CLAIMED (everyone must plan)
if task.status == TaskStatus.CLAIMED and not task.plan:
self.log.warning(
"Cannot start task - no plan",
task_id=str(task_id),
)
return None
# Only update started_at if this is the first time starting
if task.started_at is None:
task.started_at = datetime.now(UTC)
self._validate_and_set_status(task, TaskStatus.IN_PROGRESS)
await self.session.flush()
return task
async def block(self, task_id: UUID, blocker_task_id: UUID) -> TaskTable | None:
"""Block a task due to a dependency."""
task = await self.get(task_id)
if not task:
return None
if blocker_task_id not in task.dependency_ids:
new_deps = [*task.dependency_ids, blocker_task_id]
task.dependency_ids = new_deps
self._validate_and_set_status(task, TaskStatus.BLOCKED)
await self.session.flush()
# Update the blocker task to reference this as blocked
blocker = await self.get(blocker_task_id)
if blocker and task_id not in blocker.blocker_ids:
blocker.blocker_ids = [*blocker.blocker_ids, task_id]
await self.session.flush()
self.log.info(
"Task blocked",
task_id=str(task_id),
blocker_id=str(blocker_task_id),
)
# Index lifecycle event (fire-and-forget)
blocker_title = blocker.title if blocker else "unknown"
bg_task = asyncio.create_task(
self._index_lifecycle_event_background(
task_id=task_id,
event_type="block",
task_title=task.title,
task_team=task.team,
details={
"blocker_task_id": str(blocker_task_id),
"blocker_title": blocker_title,
},
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def soft_block(
self,
task_id: UUID,
reason: str,
blocker_type: str,
what_needed: str,
) -> TaskTable | None:
"""
Block a task due to an external factor (not a task dependency).
Unlike `block()` which requires another task as the blocker,
this method handles soft blocks like:
- External dependencies (waiting for API access, credentials)
- Questions that need PM/stakeholder input
- Technical blockers (infrastructure issues)
Args:
task_id: The task to block
reason: Why the task is blocked
blocker_type: Type of blocker (external/internal/question/dependency)
what_needed: What is needed to unblock
Returns:
The blocked task, or None if blocking not allowed
"""
task = await self.get(task_id)
if not task:
return None
if task.status != TaskStatus.IN_PROGRESS:
return None
# Build blocker note for dev_notes
blocker_note = (
f"[BLOCKED - {blocker_type.upper()}]\n"
f"Reason: {reason}\n"
f"What's needed: {what_needed}"
)
existing_notes = task.dev_notes or ""
if existing_notes:
task.dev_notes = f"{existing_notes}\n\n{blocker_note}"
else:
task.dev_notes = blocker_note
task.status = TaskStatus.BLOCKED
await self.session.flush()
# Index blocker as error pattern (fire-and-forget)
blocker_info = {
"type": blocker_type,
"title": task.title,
"reason": reason,
"what_needed": what_needed,
}
bg_task = asyncio.create_task(
self._index_blocker_background(
require_uuid(task.id), task.team, blocker_info
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
self.log.info(
"Task soft-blocked",
task_id=str(task_id),
blocker_type=blocker_type,
reason=reason,
)
return task
async def unblock(self, task_id: UUID) -> TaskTable | None:
"""Unblock a task and resume to in_progress."""
task = await self.get(task_id)
if not task:
return None
if task.status != TaskStatus.BLOCKED:
return None
task.status = TaskStatus.IN_PROGRESS
await self.session.flush()
self.log.info("Task unblocked", task_id=str(task_id))
# Index lifecycle event (fire-and-forget)
bg_task = asyncio.create_task(
self._index_lifecycle_event_background(
task_id=task_id,
event_type="unblock",
task_title=task.title,
task_team=task.team,
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def pause(self, task_id: UUID) -> TaskTable | None:
"""Pause a task."""
task = await self.get(task_id)
if not task:
return None
if task.status != TaskStatus.IN_PROGRESS:
return None
task.status = TaskStatus.PAUSED
await self.session.flush()
self.log.info("Task paused", task_id=str(task_id))
# Index lifecycle event (fire-and-forget)
bg_task = asyncio.create_task(
self._index_lifecycle_event_background(
task_id=task_id,
event_type="pause",
task_title=task.title,
task_team=task.team,
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def resume(self, task_id: UUID) -> TaskTable | None:
"""Resume a paused task."""
task = await self.get(task_id)
if not task:
return None
if task.status != TaskStatus.PAUSED:
return None
task.status = TaskStatus.IN_PROGRESS
await self.session.flush()
self.log.info("Task resumed", task_id=str(task_id))
# Index lifecycle event (fire-and-forget)
bg_task = asyncio.create_task(
self._index_lifecycle_event_background(
task_id=task_id,
event_type="resume",
task_title=task.title,
task_team=task.team,
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def submit_for_verification(self, task_id: UUID) -> TaskTable | None:
"""Submit task for self-verification."""
task = await self.get(task_id)
if not task:
return None
if task.status != TaskStatus.IN_PROGRESS:
return None
task.status = TaskStatus.VERIFYING
await self.session.flush()
self.log.info("Task submitted for verification", task_id=str(task_id))
return task
async def submit_for_qa(self, task_id: UUID) -> TaskTable | None:
"""Submit task for QA review."""
task = await self.get(task_id)
if not task:
return None
if task.status != TaskStatus.VERIFYING:
return None
# Store original developer BEFORE clearing assignment - authoritative
# record for self-review prevention (QA can't review own work).
original_dev = str(task.assigned_to) if task.assigned_to else None
if original_dev:
task.quick_context = f"original_developer:{original_dev}"
# Clear assignment so QA can claim the task
# The original developer is preserved in quick_context
task.assigned_to = None
task.self_verified = True
task.status = TaskStatus.AWAITING_QA
await self.session.flush()
self.log.info(
"Task submitted for QA",
task_id=str(task_id),
original_developer=original_dev,
)
return task
async def pass_qa(
self, task_id: UUID, notes: str | None = None
) -> TaskTable | None:
"""Mark task as passed QA.
QA workflow: awaiting_qa → claimed → in_progress → pass_qa
→ awaiting_documentation. Accept claimed/in_progress status.
"""
task = await self.get(task_id)
if not task:
return None
# Accept tasks QA is actively working on (claimed or in_progress)
# as well as awaiting_qa (for direct pass without starting)
valid_statuses = {
TaskStatus.AWAITING_QA,
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
}
if task.status not in valid_statuses:
return None
if notes:
task.qa_notes = notes
# Store QA agent before clearing assignment
qa_agent_id = task.assigned_to
# Clear assignment so documenter can claim the task
task.assigned_to = None
task.qa_verified = True
task.status = TaskStatus.AWAITING_DOCUMENTATION
await self.session.flush()
# Index positive QA review (fire-and-forget)
bg_task = asyncio.create_task(
self._index_qa_review_background(
require_uuid(task.id),
task.quick_context,
True,
notes or "Passed QA review",
to_python_uuid(qa_agent_id),
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
self.log.info("Task passed QA", task_id=str(task_id))
return task
async def fail_qa(self, task_id: UUID, notes: str) -> TaskTable | None:
"""
Mark task as failed QA and reassign to original developer.
When QA fails a task, it goes back to the original developer for revision.
The original developer is extracted from quick_context which stores
"original_developer:{uuid}" when the task was submitted to QA.
QA workflow: awaiting_qa → claimed → in_progress → fail_qa → needs_revision
So we need to accept tasks in claimed or in_progress status.
"""
task = await self.get(task_id)
if not task:
return None
# Accept tasks QA is actively working on
valid_statuses = {
TaskStatus.AWAITING_QA,
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
}
if task.status not in valid_statuses:
return None
task.qa_notes = notes
task.qa_verified = False
task.status = TaskStatus.NEEDS_REVISION
# Store QA agent before reassigning
qa_agent_id = task.assigned_to
# Reassign to original developer so they can work on revisions
original_dev = extract_original_developer(task.quick_context)
if original_dev:
task.assigned_to = cast("Any", UUID(original_dev))
self.log.info(
"Task reassigned to original developer for revision",
task_id=str(task_id),
original_developer=original_dev,
)
else:
# If no original developer found, unassign so it can be claimed
task.assigned_to = None
self.log.warning(
"No original developer found, task unassigned",
task_id=str(task_id),
)
await self.session.flush()
# Index negative QA review (fire-and-forget)
review_task = asyncio.create_task(
self._index_qa_review_background(
require_uuid(task.id),
task.quick_context,
False,
notes,
to_python_uuid(qa_agent_id),
)
)
self._background_tasks.add(review_task)
review_task.add_done_callback(self._background_tasks.discard)
# Index issues as error patterns (fire-and-forget)
error_task = asyncio.create_task(
self._index_qa_errors_background(
require_uuid(task.id), task.title, task.team, notes
)
)
self._background_tasks.add(error_task)
error_task.add_done_callback(self._background_tasks.discard)
self.log.info("Task failed QA", task_id=str(task_id))
return task
async def docs_complete(
self,
task_id: UUID,
doc_notes: str | None = None,
) -> TaskTable | None:
"""
Mark documentation as complete (documenter only).
Documenter workflow: awaiting_documentation → claim → plan → start
→ docs_complete. Accept claimed/in_progress (documenter working).
Args:
task_id: The task to mark docs complete
doc_notes: Optional notes about the documentation
Returns:
The updated task or None if not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Accept documenter workflow statuses: awaiting_documentation, claimed,
# in_progress (documenter actively working on documentation)
valid_statuses = {
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
}
if task.status not in valid_statuses:
self.log.warning(
"Cannot mark docs complete - invalid status for documenter workflow",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Check all descendants are in terminal states before escalating
all_descendants = await self.get_all_descendants(task_id)
incomplete = [
d
for d in all_descendants
if d.status not in (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
]
if incomplete:
self.log.warning(
"Cannot mark docs complete - incomplete descendants",
task_id=str(task_id),
incomplete_count=len(incomplete),
incomplete_ids=[str(d.id) for d in incomplete[:5]],
)
return None
# Store doc notes in quick_context (no dedicated field for doc_notes)
if doc_notes:
existing_context = task.quick_context or ""
doc_note_entry = f"doc_notes:{doc_notes}"
task.quick_context = (
f"{existing_context}\n{doc_note_entry}".strip()
if existing_context
else doc_note_entry
)
# Mark docs as complete
task.docs_complete = True
# Store documenter in quick_context for reference
if task.assigned_to:
existing_context = task.quick_context or ""
if "documenter:" not in existing_context:
doc_context = f"documenter:{task.assigned_to}"
task.quick_context = (
f"{existing_context}\n{doc_context}".strip()
if existing_context
else doc_context
)
# For git tasks: check if BOTH docs_complete AND pr_created are true
# (Developer works in parallel, creating PR)
from roboco.enforcement.task_lifecycle import check_parallel_completion
ready_for_pm = check_parallel_completion(
docs_complete=True, # We just set this
pr_created=task.pr_created,
requires_git=task.requires_git,
)
if ready_for_pm:
# Both conditions met - transition to PM review (validated)
self._validate_and_set_status(
task, TaskStatus.AWAITING_PM_REVIEW, "documenter"
)
# Clear assignment so PM can claim the task for review
task.assigned_to = None
self.log.info(
"Documentation complete, awaiting PM review",
task_id=str(task_id),
requires_git=task.requires_git,
pr_created=task.pr_created,
)
else:
# Git task: docs done but PR not yet created
# Stay in awaiting_documentation, waiting for developer to create PR
self.log.info(
"Documentation complete, waiting for developer to create PR",
task_id=str(task_id),
docs_complete=True,
pr_created=task.pr_created,
)
await self.session.flush()
# Index documentation artifacts (fire-and-forget)
if task.documents:
bg_task = asyncio.create_task(
self._index_docs_background(require_uuid(task.id), task.documents)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def mark_pr_created(
self,
task_id: UUID,
pr_number: int,
pr_url: str,
) -> TaskTable | None:
"""
Mark that developer has created a PR for the task.
Called when developer uses roboco_git_create_pr(). This method:
1. Sets pr_created=True, pr_number, pr_url on the task
2. Checks if docs_complete is also True
3. If both complete, transitions to awaiting_pm_review
This works in parallel with documenter's docs_complete().
Args:
task_id: The task ID
pr_number: GitHub/GitLab PR number
pr_url: Full URL to the PR
Returns:
The updated task or None if not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Only allow in awaiting_documentation (parallel execution phase)
if task.status != TaskStatus.AWAITING_DOCUMENTATION:
self.log.warning(
"Cannot mark PR created - task not in awaiting_documentation",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Set PR info
task.pr_created = True
task.pr_number = pr_number
task.pr_url = pr_url
# Store developer who created PR in quick_context
if task.assigned_to:
existing_context = task.quick_context or ""
if "pr_author:" not in existing_context:
pr_context = f"pr_author:{task.assigned_to}"
task.quick_context = (
f"{existing_context}\n{pr_context}".strip()
if existing_context
else pr_context
)
# Check if BOTH docs_complete AND pr_created are now true
from roboco.enforcement.task_lifecycle import check_parallel_completion
ready_for_pm = check_parallel_completion(
docs_complete=task.docs_complete,
pr_created=True, # We just set this
requires_git=task.requires_git,
)
if ready_for_pm:
# Both conditions met - transition to PM review
task.status = TaskStatus.AWAITING_PM_REVIEW
# Clear assignment so PM can claim the task for review
task.assigned_to = None
self.log.info(
"PR created, awaiting PM review",
task_id=str(task_id),
pr_number=pr_number,
docs_complete=task.docs_complete,
)
else:
# PR created but docs not yet complete
# Stay in awaiting_documentation, waiting for documenter
self.log.info(
"PR created, waiting for documenter to complete",
task_id=str(task_id),
pr_number=pr_number,
docs_complete=task.docs_complete,
)
await self.session.flush()
return task
async def submit_for_pm_review(
self,
task_id: UUID,
notes: str | None = None,
) -> TaskTable | None:
"""
Submit a task directly for PM review (any assigned agent).
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.
Transitions task from IN_PROGRESS to AWAITING_PM_REVIEW.
Args:
task_id: The task to submit
notes: Optional completion notes
Returns:
The updated task or None if not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Only allow submission from in_progress status
if task.status != TaskStatus.IN_PROGRESS:
self.log.warning(
"Cannot submit for PM review - task not in progress",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Check all descendants are in terminal states before escalating
all_descendants = await self.get_all_descendants(task_id)
incomplete = [
d
for d in all_descendants
if d.status not in (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
]
if incomplete:
self.log.warning(
"Cannot submit for PM review - incomplete descendants",
task_id=str(task_id),
incomplete_count=len(incomplete),
incomplete_ids=[str(d.id) for d in incomplete[:5]],
)
return None
# Store notes in quick_context
if notes:
existing_context = task.quick_context or ""
note_entry = f"completion_notes:{notes}"
task.quick_context = (
f"{existing_context}\n{note_entry}".strip()
if existing_context
else note_entry
)
task.status = TaskStatus.AWAITING_PM_REVIEW
await self.session.flush()
self.log.info(
"Task submitted for PM review",
task_id=str(task_id),
)
return task
async def _get_completing_agent_role(self, agent_id: UUID | None) -> str | None:
"""Get the role of the completing agent."""
if not agent_id:
return None
agent_result = await self.session.execute(
select(AgentTable).where(AgentTable.id == agent_id)
)
agent = agent_result.scalar_one_or_none()
if agent and agent.role:
return agent.role.value if hasattr(agent.role, "value") else str(agent.role)
return None
def _is_valid_completion_status(
self, task: TaskTable, agent_id: UUID | None
) -> bool:
"""Check if task is in a valid status for completion."""
if task.status == TaskStatus.AWAITING_PM_REVIEW:
return True
is_own_task = agent_id and task.assigned_to == agent_id
return task.status == TaskStatus.IN_PROGRESS and bool(is_own_task)
async def _handle_cell_pm_escalation(
self, task: TaskTable, task_id: UUID, agent_id: UUID | None
) -> TaskTable | None:
"""Handle Cell PM escalation to Main PM. Returns task if escalated."""
main_pm_result = await self.session.execute(
select(AgentTable).where(AgentTable.role == AgentRole.MAIN_PM)
)
main_pm = main_pm_result.scalar_one_or_none()
if not main_pm:
self.log.warning(
"No Main PM found - proceeding with completion", task_id=str(task_id)
)
return None
task.assigned_to = cast("Any", main_pm.id)
await self.session.flush()
await self._emit_task_event(
EventType.TASK_ESCALATED_TO_MAIN_PM,
task_id,
{
"main_pm_id": str(main_pm.id),
"cell_pm_id": str(agent_id) if agent_id else None,
},
)
self.log.info(
"Cell PM approved - escalating to Main PM",
task_id=str(task_id),
main_pm_id=str(main_pm.id),
)
return task
async def _validate_completion_prerequisites(
self, task: TaskTable, task_id: UUID, agent_id: UUID | None
) -> list[TaskTable] | None:
"""Validate task can be completed. Returns descendants or None."""
if not self._is_valid_completion_status(task, agent_id):
self.log.warning("Cannot complete - invalid status", task_id=str(task_id))
return None
all_descendants = await self.get_all_descendants(task_id)
incomplete = [
st
for st in all_descendants
if st.status not in (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
]
if incomplete:
self.log.warning(
"Cannot complete - incomplete descendants", task_id=str(task_id)
)
return None
return all_descendants
async def _trigger_completion_hooks(
self, task: TaskTable, agent_id: UUID | None
) -> None:
"""Trigger background RAG indexing hooks after completion."""
bg_task = asyncio.create_task(
self._extract_completion_learnings(task, agent_id)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
if task.commits:
code_task = asyncio.create_task(
self._index_code_changes_background(
require_uuid(task.id),
task.commits,
task.team.value if task.team else "default",
)
)
self._background_tasks.add(code_task)
code_task.add_done_callback(self._background_tasks.discard)
if task.dev_notes:
decision_task = asyncio.create_task(
self._index_decisions_background(
require_uuid(task.id),
task.title,
task.team,
task.dev_notes,
to_python_uuid(task.assigned_to),
)
)
self._background_tasks.add(decision_task)
decision_task.add_done_callback(self._background_tasks.discard)
async def complete(
self,
task_id: UUID,
agent_id: UUID | None = None,
force_with_cancelled: bool = False,
justification: str | None = None,
) -> TaskTable | None:
"""
Mark task as completed (PM only).
Approval hierarchy:
1. Cell PM reviews → reassigns to Main PM (same awaiting_pm_review state)
2. Main PM reviews leaf task → completes
3. Main PM reviews parent task (all descendants terminal) → escalates to CEO
"""
task = await self.get(task_id)
if not task:
return None
completing_agent_role = await self._get_completing_agent_role(agent_id)
all_descendants = await self._validate_completion_prerequisites(
task, task_id, agent_id
)
if all_descendants is None:
return None
# APPROVAL HIERARCHY: Cell PM → Main PM → CEO
if task.status == TaskStatus.AWAITING_PM_REVIEW:
if completing_agent_role == "cell_pm":
escalated = await self._handle_cell_pm_escalation(
task, task_id, agent_id
)
if escalated:
return escalated
# Only escalate root-level parents to CEO (subtasks complete directly)
is_root_parent = all_descendants and not task.parent_task_id
if completing_agent_role == "main_pm" and is_root_parent:
self.log.info(
"Main PM approved root parent - escalating to CEO",
task_id=str(task_id),
)
return await self.escalate_to_ceo(task_id, "main_pm")
# Handle cancelled descendants - require force flag and justification
cancelled = [st for st in all_descendants if st.status == TaskStatus.CANCELLED]
if cancelled and (not force_with_cancelled or not justification):
self.log.warning(
"Cannot complete - cancelled descendants", task_id=str(task_id)
)
return None
task.completed_at = datetime.now(UTC)
self._validate_and_set_status(
task, TaskStatus.COMPLETED, completing_agent_role or "cell_pm"
)
await self.session.flush()
await self._trigger_completion_hooks(task, agent_id)
await self._unblock_dependents(task_id)
return task
# =========================================================================
# CEO APPROVAL WORKFLOW
# =========================================================================
async def escalate_to_ceo(
self,
task_id: UUID,
agent_role: str = "cell_pm",
notes: str | None = None,
) -> TaskTable | None:
"""
Escalate a task to CEO for final approval (PM only).
Used for major tasks that require CEO sign-off before merge:
- Parent tasks with subtasks
- High-priority features
- Breaking changes
Args:
task_id: The task to escalate
agent_role: Role of the agent escalating (must be PM)
notes: Optional notes for the CEO
Returns:
The escalated task or None if escalation not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Only allow escalation from awaiting_pm_review
if task.status != TaskStatus.AWAITING_PM_REVIEW:
self.log.warning(
"Cannot escalate to CEO - task not in PM review",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Only parent tasks can be escalated to CEO (not subtasks)
if task.parent_task_id:
self.log.warning(
"Cannot escalate subtask to CEO - only parent tasks allowed",
task_id=str(task_id),
parent_task_id=str(task.parent_task_id),
)
return None
# Store escalation notes
if notes:
existing_context = task.quick_context or ""
note_entry = f"escalation_notes:{notes}"
task.quick_context = (
f"{existing_context}\n{note_entry}".strip()
if existing_context
else note_entry
)
# Validate transition with PM role requirement
self._validate_and_set_status(
task, TaskStatus.AWAITING_CEO_APPROVAL, agent_role
)
await self.session.flush()
# Emit event for CEO approval queue
await self._emit_task_event(
EventType.TASK_AWAITING_CEO_APPROVAL,
task_id,
{"escalated_by_role": agent_role, "notes": notes},
)
self.log.info(
"Task escalated to CEO for approval",
task_id=str(task_id),
escalated_by_role=agent_role,
)
return task
async def ceo_approve(
self,
task_id: UUID,
notes: str | None = None,
) -> TaskTable | None:
"""
CEO approves and completes a task.
Final approval step for major tasks. Only CEO can perform this action.
Args:
task_id: The task to approve
notes: Optional CEO notes
Returns:
The completed task or None if approval not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Only allow approval from awaiting_ceo_approval
if task.status != TaskStatus.AWAITING_CEO_APPROVAL:
self.log.warning(
"Cannot CEO approve - task not awaiting CEO approval",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Store CEO notes
if notes:
existing_context = task.quick_context or ""
note_entry = f"ceo_approval_notes:{notes}"
task.quick_context = (
f"{existing_context}\n{note_entry}".strip()
if existing_context
else note_entry
)
task.completed_at = datetime.now(UTC)
# Validate transition with CEO role requirement
self._validate_and_set_status(task, TaskStatus.COMPLETED, "ceo")
await self.session.flush()
# Extract learnings (fire-and-forget)
bg_task = asyncio.create_task(self._extract_completion_learnings(task, None))
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
# Unblock any tasks waiting on this one
await self._unblock_dependents(task_id)
# Emit event for CEO approval
await self._emit_task_event(
EventType.TASK_CEO_APPROVED,
task_id,
{"notes": notes},
)
self.log.info(
"Task approved by CEO",
task_id=str(task_id),
)
return task
async def ceo_reject(
self,
task_id: UUID,
reason: str,
) -> TaskTable | None:
"""
CEO rejects a task and sends back for revision.
Task goes back to NEEDS_REVISION and is reassigned to the
original developer (if tracked in quick_context).
Args:
task_id: The task to reject
reason: Required reason for rejection
Returns:
The rejected task or None if rejection not allowed
"""
task = await self.get(task_id)
if not task:
return None
# Only allow rejection from awaiting_ceo_approval
if task.status != TaskStatus.AWAITING_CEO_APPROVAL:
self.log.warning(
"Cannot CEO reject - task not awaiting CEO approval",
task_id=str(task_id),
current_status=task.status.value,
)
return None
# Store CEO rejection reason
existing_context = task.quick_context or ""
rejection_entry = f"ceo_rejection:{reason}"
task.quick_context = (
f"{existing_context}\n{rejection_entry}".strip()
if existing_context
else rejection_entry
)
# Validate transition with CEO role requirement
self._validate_and_set_status(task, TaskStatus.NEEDS_REVISION, "ceo")
# Try to reassign to original developer
original_dev = extract_original_developer(task.quick_context)
if original_dev:
task.assigned_to = cast("Any", UUID(original_dev))
self.log.info(
"Task reassigned to original developer after CEO rejection",
task_id=str(task_id),
original_developer=original_dev,
)
else:
# Clear assignment so it can be claimed
task.assigned_to = None
await self.session.flush()
# Emit event for CEO rejection
await self._emit_task_event(
EventType.TASK_CEO_REJECTED,
task_id,
{"reason": reason, "reassigned_to": original_dev},
)
self.log.info(
"Task rejected by CEO",
task_id=str(task_id),
reason=reason,
)
return task
async def cancel(
self, task_id: UUID, agent_role: str = "cell_pm"
) -> TaskTable | None:
"""Cancel a task and all its descendants (PM only)."""
task = await self.get(task_id)
if not task:
return None
# Cancel all descendants first (children, grandchildren, etc.)
# Skip tasks already in terminal states (completed or cancelled)
descendants = await self.get_all_descendants(task_id)
cancelled_count = 0
for descendant in descendants:
if descendant.status not in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
descendant.status = TaskStatus.CANCELLED
cancelled_count += 1
if cancelled_count > 0:
self.log.info(
"Cascaded cancel to descendants",
task_id=str(task_id),
cancelled_count=cancelled_count,
)
# Validate transition with PM role requirement
self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role)
await self.session.flush()
# Index lifecycle event (fire-and-forget)
bg_task = asyncio.create_task(
self._index_lifecycle_event_background(
task_id=task_id,
event_type="cancel",
task_title=task.title,
task_team=task.team,
details={
"cancelled_by_role": agent_role,
"descendants_cancelled": cancelled_count,
},
)
)
self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard)
return task
async def _emit_task_event(
self,
event_type: EventType,
task_id: UUID,
data: dict[str, Any] | None = None,
) -> None:
"""Emit a task lifecycle event to the event bus.
Events are published asynchronously. Failures are logged but
do not interrupt the calling operation.
Args:
event_type: The type of event to emit
task_id: The task this event relates to
data: Optional additional event data
"""
try:
bus = get_event_bus()
if bus.is_connected():
event_data = {"task_id": str(task_id)}
if data:
event_data.update(data)
await bus.publish(Event(type=event_type, data=event_data))
except Exception as e:
self.log.warning(
"Failed to emit task event",
event_type=event_type.value,
task_id=str(task_id),
error=str(e),
)
async def _unblock_dependents(self, completed_task_id: UUID) -> None:
"""Unblock tasks that were waiting on the completed task."""
result = await self.session.execute(
select(TaskTable).where(
TaskTable.dependency_ids.contains([completed_task_id])
)
)
blocked_tasks = result.scalars().all()
for task in blocked_tasks:
# Remove the completed task from dependencies
task.dependency_ids = [
dep_id for dep_id in task.dependency_ids if dep_id != completed_task_id
]
# If no more dependencies, unblock
if not task.dependency_ids and task.status == TaskStatus.BLOCKED:
task.status = TaskStatus.IN_PROGRESS
self.log.info(
"Task auto-unblocked",
task_id=str(task.id),
completed_dependency=str(completed_task_id),
)
await self.session.flush()
# =========================================================================
# PROGRESS AND CHECKPOINTS
# =========================================================================
async def add_progress(
self,
task_id: UUID,
agent_id: UUID,
message: str,
percentage: int | None = None,
) -> TaskTable | None:
"""Add a progress update to a task."""
task = await self.get(task_id)
if not task:
return None
update = {
"timestamp": datetime.now(UTC).isoformat(),
"agent_id": str(agent_id),
"message": message,
"percentage": percentage,
}
task.progress_updates = [*task.progress_updates, update]
await self.session.flush()
return task
async def add_checkpoint(
self,
task_id: UUID,
agent_id: UUID,
state_summary: str,
remaining_work: list[str],
notes: str | None = None,
) -> TaskTable | None:
"""Add a checkpoint for state recovery."""
task = await self.get(task_id)
if not task:
return None
checkpoint = {
"id": str(UUID(int=len(task.checkpoints))),
"timestamp": datetime.now(UTC).isoformat(),
"agent_id": str(agent_id),
"state_summary": state_summary,
"remaining_work": remaining_work,
"notes": notes,
}
task.checkpoints = [*task.checkpoints, checkpoint]
await self.session.flush()
return task
async def add_commit(
self,
task_id: UUID,
hash: str,
message: str,
agent_id: UUID | None = None,
) -> TaskTable | None:
"""Link a commit to a task."""
task = await self.get(task_id)
if not task:
return None
commit = {
"hash": hash,
"message": message,
"timestamp": datetime.now(UTC).isoformat(),
"author_agent_id": str(agent_id) if agent_id else None,
}
task.commits = [*task.commits, commit]
await self.session.flush()
return task
# =========================================================================
# QUERIES
# =========================================================================
async def list_all(
self,
limit: int = 100,
offset: int = 0,
) -> list[TaskTable]:
"""List all tasks with pagination."""
result = await self.session.execute(
select(TaskTable)
.order_by(TaskTable.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def list_by_team(
self,
team: Team,
status: TaskStatus | None = None,
limit: int = 100,
) -> list[TaskTable]:
"""List tasks for a team, ordered by priority, sequence, created_at."""
query = select(TaskTable).where(TaskTable.team == team)
if status:
query = query.where(TaskTable.status == status)
query = query.order_by(
TaskTable.priority,
TaskTable.sequence,
TaskTable.created_at,
)
query = query.limit(limit)
result = await self.session.execute(query)
return list(result.scalars().all())
async def list_by_assignee(
self,
agent_id: UUID,
status: TaskStatus | None = None,
) -> list[TaskTable]:
"""List tasks assigned to an agent."""
query = select(TaskTable).where(TaskTable.assigned_to == agent_id)
if status:
query = query.where(TaskTable.status == status)
query = query.order_by(TaskTable.priority, TaskTable.created_at.desc())
result = await self.session.execute(query)
return list(result.scalars().all())
async def list_by_team_or_assignee(
self,
team: Team | None = None,
agent_id: UUID | None = None,
status: TaskStatus | None = None,
) -> list[TaskTable]:
"""
List tasks by team OR assignee.
Useful for finding tasks an agent could work on (their assigned tasks
or unassigned tasks in their team).
"""
conditions = []
if team:
conditions.append(
and_(TaskTable.team == team, TaskTable.assigned_to.is_(None))
)
if agent_id:
conditions.append(TaskTable.assigned_to == agent_id)
if not conditions:
return []
query = select(TaskTable).where(or_(*conditions))
if status:
query = query.where(TaskTable.status == status)
query = query.order_by(TaskTable.priority, TaskTable.created_at.desc())
result = await self.session.execute(query)
return list(result.scalars().all())
async def list_by_status(
self,
status: TaskStatus,
team: Team | None = None,
) -> list[TaskTable]:
"""List tasks by status, ordered by priority, sequence, created_at."""
query = select(TaskTable).where(TaskTable.status == status)
if team:
query = query.where(TaskTable.team == team)
# Order by priority first, then sequence (for sibling order), then created_at
query = query.order_by(
TaskTable.priority,
TaskTable.sequence,
TaskTable.created_at,
)
result = await self.session.execute(query)
return list(result.scalars().all())
async def list_pending(
self,
team: Team | None = None,
filter_by_dependencies: bool = True,
) -> list[TaskTable]:
"""
List pending tasks (available to claim).
Args:
team: Filter by team
filter_by_dependencies: If True, exclude tasks with incomplete dependencies
Returns:
List of pending tasks, ordered by priority, sequence, then created_at
"""
tasks = await self.list_by_status(TaskStatus.PENDING, team)
if not filter_by_dependencies:
return tasks
# Filter out tasks whose dependencies aren't complete
available_tasks = []
for task in tasks:
if not task.dependency_ids:
available_tasks.append(task)
continue
# Check if all dependencies are complete
deps_result = await self.session.execute(
select(TaskTable.status).where(TaskTable.id.in_(task.dependency_ids))
)
dep_statuses = deps_result.scalars().all()
# All dependencies must be COMPLETED or CANCELLED
terminal_statuses = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
if all(s in terminal_statuses for s in dep_statuses):
available_tasks.append(task)
return available_tasks
async def list_blocked(self, team: Team | None = None) -> list[TaskTable]:
"""List blocked tasks."""
return await self.list_by_status(TaskStatus.BLOCKED, team)
async def list_awaiting_qa(self, team: Team | None = None) -> list[TaskTable]:
"""List tasks awaiting QA review."""
return await self.list_by_status(TaskStatus.AWAITING_QA, team)
async def list_awaiting_docs(self, team: Team | None = None) -> list[TaskTable]:
"""List tasks awaiting documentation."""
return await self.list_by_status(TaskStatus.AWAITING_DOCUMENTATION, team)
async def list_awaiting_pm_review(
self, team: Team | None = None
) -> list[TaskTable]:
"""List tasks awaiting PM review."""
return await self.list_by_status(TaskStatus.AWAITING_PM_REVIEW, team)
async def list_awaiting_ceo_approval(self) -> list[TaskTable]:
"""List tasks awaiting CEO approval (org-wide, no team filter)."""
return await self.list_by_status(TaskStatus.AWAITING_CEO_APPROVAL)
async def get_subtasks(self, parent_task_id: UUID) -> list[TaskTable]:
"""Get all subtasks of a parent task."""
result = await self.session.execute(
select(TaskTable)
.where(TaskTable.parent_task_id == parent_task_id)
.order_by(TaskTable.created_at)
)
return list(result.scalars().all())
async def get_all_descendants(self, task_id: UUID) -> list[TaskTable]:
"""Recursively get ALL descendant tasks (children, grandchildren, etc.).
Uses iterative BFS to avoid recursion limits and handle arbitrary depth.
"""
descendants: list[TaskTable] = []
to_process: list[UUID] = [task_id]
while to_process:
current_id = to_process.pop(0)
children = await self.get_subtasks(current_id)
for child in children:
descendants.append(child)
# child.id is SQLAlchemy Mapped[UUID]
# but resolves to uuid.UUID at runtime
to_process.append(child.id) # type: ignore[arg-type]
return descendants
# =========================================================================
# STATISTICS
# =========================================================================
async def count_by_status(self, team: Team | None = None) -> dict[str, int]:
"""Count tasks by status."""
query = select(
TaskTable.status,
func.count(TaskTable.id),
).group_by(TaskTable.status)
if team:
query = query.where(TaskTable.team == team)
result = await self.session.execute(query)
return {row[0].value: row[1] for row in result.all()}
async def count_by_team(self) -> dict[str, int]:
"""Count tasks by team."""
result = await self.session.execute(
select(
TaskTable.team,
func.count(TaskTable.id),
).group_by(TaskTable.team)
)
return {row[0].value: row[1] for row in result.all()}
async def get_active_count(self, agent_id: UUID) -> int:
"""Get count of active tasks for an agent."""
result = await self.session.execute(
select(func.count(TaskTable.id)).where(
and_(
TaskTable.assigned_to == agent_id,
TaskTable.status.in_(
[
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
TaskStatus.VERIFYING,
]
),
)
)
)
return result.scalar() or 0
# =============================================================================
# PM RESOLUTION HELPERS
# =============================================================================
async def resolve_pm_for_substitute(
db: AsyncSession,
agent_slug: str | None,
task_team: Team | None,
) -> tuple[str | None, UUID | None]:
"""
Resolve the PM slug and UUID for a substitute request.
Args:
db: Database session
agent_slug: The agent's slug for PM lookup
task_team: The task's team for fallback PM lookup
Returns:
Tuple of (pm_slug, pm_uuid) or (None, None) if not found
"""
from roboco.agents_config import get_pm_for_agent, get_pm_for_team
target_pm_slug = None
if agent_slug:
target_pm_slug = get_pm_for_agent(agent_slug)
if not target_pm_slug and task_team:
target_pm_slug = get_pm_for_team(task_team.value)
if not target_pm_slug:
return None, None
pm_result = await db.execute(
select(AgentTable).where(AgentTable.slug == target_pm_slug)
)
pm_agent = pm_result.scalar_one_or_none()
return target_pm_slug, to_python_uuid(pm_agent.id) if pm_agent else None
async def notify_pm_for_substitute(
db: AsyncSession,
pm_slug: str,
task_id: UUID,
from_agent_id: UUID,
message: tuple[str, str],
) -> None:
"""
Create and deliver a notification to PM for substitute request.
Args:
db: Database session
pm_slug: Target PM's slug
task_id: The task being substituted
from_agent_id: Agent requesting substitution
message: Tuple of (subject, body) for the notification
"""
from roboco.db.tables import NotificationTable
from roboco.services.notification_delivery import get_notification_delivery_service
pm_result = await db.execute(select(AgentTable).where(AgentTable.slug == pm_slug))
pm_agent = pm_result.scalar_one_or_none()
if not pm_agent:
return
subject, body = message
notification = NotificationTable(
type="task_assignment",
priority="high",
from_agent=from_agent_id,
to_agents=[pm_agent.id],
subject=subject,
body=body,
related_task_id=task_id,
requires_ack=True,
)
db.add(notification)
await db.flush()
delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(require_uuid(notification.id))
# =============================================================================
# SERVICE FACTORY
# =============================================================================
def get_task_service(session: AsyncSession) -> TaskService:
"""Get a TaskService instance."""
return TaskService(session)