Improved workflow and task lifecycle

This commit is contained in:
Renn F
2025-12-21 03:09:12 +01:00
parent 3122d2759e
commit b04993d8b8
13 changed files with 186 additions and 77 deletions
+2 -2
View File
@@ -85,8 +85,8 @@ If none: `roboco_agent_idle()`
### 6. WRITE ### 6. WRITE
**File Paths** - Write documentation to `/app/docs/`: **File Paths** - Write documentation to `/app/docs/`:
- `/app/docs/backend/` - Backend documentation - `/app/docs/backend/` - Backend documentation
- `/app/docs/api/` - API documentation - `/app/docs/backend/api/` - API documentation
- `/app/docs/changelog.md` - Changelog - `/app/docs/backend/changelog.md` - Changelog
**API Documentation** (if new/changed endpoints) **API Documentation** (if new/changed endpoints)
- Endpoint URL, method - Endpoint URL, method
+1 -1
View File
@@ -394,7 +394,7 @@ You should NOT:
BE-Dev-1: Anyone know if we have a standard approach for rate limiting? BE-Dev-1: Anyone know if we have a standard approach for rate limiting?
Auditor: I recall seeing something about this in the backend docs. Auditor: I recall seeing something about this in the backend docs.
Auditor: Check docs/patterns/rate-limiting.md if it exists. Auditor: Check docs/auditor/patterns/rate-limiting.md if it exists.
Auditor: If not, might be worth creating one after TASK-042 wraps up. Auditor: If not, might be worth creating one after TASK-042 wraps up.
``` ```
(Note: You knew about TASK-042 from observation, but phrased naturally) (Note: You knew about TASK-042 from observation, but phrased naturally)
+2 -2
View File
@@ -85,8 +85,8 @@ If none: `roboco_agent_idle()`
### 6. WRITE ### 6. WRITE
**File Paths** - Write documentation to `/app/docs/`: **File Paths** - Write documentation to `/app/docs/`:
- `/app/docs/frontend/` - Frontend documentation - `/app/docs/frontend/` - Frontend documentation
- `/app/docs/components/` - Component documentation - `/app/docs/frontend/components/` - Component documentation
- `/app/docs/changelog.md` - Changelog - `/app/docs/frontend/changelog.md` - Changelog
**Component Documentation** **Component Documentation**
- Props interface - Props interface
+2 -2
View File
@@ -85,8 +85,8 @@ If none: `roboco_agent_idle()`
### 6. WRITE ### 6. WRITE
**File Paths** - Write documentation to `/app/docs/`: **File Paths** - Write documentation to `/app/docs/`:
- `/app/docs/ux_ui/` - UX/UI documentation - `/app/docs/ux_ui/` - UX/UI documentation
- `/app/docs/design-system/` - Design system documentation - `/app/docs/ux_ui/design-system/` - Design system documentation
- `/app/docs/changelog.md` - Changelog - `/app/docs/ux_ui/changelog.md` - Changelog
**Component Guidelines** **Component Guidelines**
- When to use this component - When to use this component
+2 -2
View File
@@ -259,7 +259,7 @@ Respond with structured analysis.
DocumentSpec( DocumentSpec(
doc_type=DocType.API, doc_type=DocType.API,
title=f"API documentation for {ctx.title}", title=f"API documentation for {ctx.title}",
path="docs/api/", path="docs/backend/api/",
priority="required", priority="required",
) )
) )
@@ -270,7 +270,7 @@ Respond with structured analysis.
DocumentSpec( DocumentSpec(
doc_type=DocType.COMPONENT, doc_type=DocType.COMPONENT,
title=f"Component documentation for {ctx.title}", title=f"Component documentation for {ctx.title}",
path="docs/components/", path="docs/frontend/components/",
priority="required", priority="required",
) )
) )
+1 -1
View File
@@ -176,7 +176,7 @@ async def send_message(
content=data.content, content=data.content,
message_type=data.type, message_type=data.type,
reply_to=data.reply_to, reply_to=data.reply_to,
mentions=data.mentions if data.mentions else None, mentions=data.mentions if data.mentions else [],
task_id=data.task_id, task_id=data.task_id,
commit_ref=data.commit_ref, commit_ref=data.commit_ref,
) )
+7 -7
View File
@@ -35,7 +35,11 @@ from roboco.models.base import TaskStatus, Team
from roboco.models.task import TaskCreate from roboco.models.task import TaskCreate
from roboco.services.audit import get_audit_service from roboco.services.audit import get_audit_service
from roboco.services.permissions import TaskAction from roboco.services.permissions import TaskAction
from roboco.services.task import TaskCreateRequest, get_task_service from roboco.services.task import (
TaskCreateRequest,
extract_original_developer,
get_task_service,
)
router = APIRouter() router = APIRouter()
@@ -714,9 +718,7 @@ async def pass_qa(
# QA cannot review their own tasks (prevent self-review) # QA cannot review their own tasks (prevent self-review)
# Check against original developer stored in quick_context, not current assigned_to # Check against original developer stored in quick_context, not current assigned_to
original_dev = None original_dev = extract_original_developer(task.quick_context)
if task.quick_context and task.quick_context.startswith("original_developer:"):
original_dev = task.quick_context.split(":", 1)[1]
if original_dev and str(agent.agent_id) == original_dev: if original_dev and str(agent.agent_id) == original_dev:
audit = get_audit_service() audit = get_audit_service()
@@ -767,9 +769,7 @@ async def fail_qa(
# QA cannot review their own tasks (prevent self-review) # QA cannot review their own tasks (prevent self-review)
# Check against original developer stored in quick_context, not current assigned_to # Check against original developer stored in quick_context, not current assigned_to
original_dev = None original_dev = extract_original_developer(task.quick_context)
if task.quick_context and task.quick_context.startswith("original_developer:"):
original_dev = task.quick_context.split(":", 1)[1]
if original_dev and str(agent.agent_id) == original_dev: if original_dev and str(agent.agent_id) == original_dev:
raise HTTPException( raise HTTPException(
+2 -2
View File
@@ -34,8 +34,8 @@ VALID_TRANSITIONS: dict[str, list[str]] = {
], ],
# Needs revision - back to work or cancel # Needs revision - back to work or cancel
"needs_revision": ["in_progress", "cancelled"], "needs_revision": ["in_progress", "cancelled"],
# Awaiting QA - can pass (to docs) or fail (needs revision) or cancel # Awaiting QA - can pass (to docs), fail (needs revision), block, or cancel
"awaiting_qa": ["awaiting_documentation", "needs_revision", "cancelled"], "awaiting_qa": ["awaiting_documentation", "needs_revision", "blocked", "cancelled"],
# Awaiting documentation - can complete or cancel # Awaiting documentation - can complete or cancel
"awaiting_documentation": ["completed", "cancelled"], "awaiting_documentation": ["completed", "cancelled"],
# Terminal states - cannot transition out # Terminal states - cannot transition out
+1 -1
View File
@@ -430,7 +430,7 @@ async def _handle_message_send(
"content": data.content, "content": data.content,
"is_reply": data.reply_to is not None, "is_reply": data.reply_to is not None,
"reply_to": data.reply_to, "reply_to": data.reply_to,
"mentions": resolved_mentions if resolved_mentions else None, "mentions": resolved_mentions,
"task_id": data.task_id, "task_id": data.task_id,
} }
+55 -26
View File
@@ -39,6 +39,7 @@ from roboco.agents_config import (
from roboco.config import settings from roboco.config import settings
from roboco.llm import ToonAdapter from roboco.llm import ToonAdapter
from roboco.mcp.schemas import TaskAssignInput, TaskCreateInput, TaskEscalateInput from roboco.mcp.schemas import TaskAssignInput, TaskCreateInput, TaskEscalateInput
from roboco.services.task import extract_original_developer
def _get_agent_headers(agent_id: str) -> dict[str, str]: def _get_agent_headers(agent_id: str) -> dict[str, str]:
@@ -216,7 +217,8 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
paused_resp.json() if paused_resp.status_code == status.HTTP_200_OK else [] paused_resp.json() if paused_resp.status_code == status.HTTP_200_OK else []
) )
# Get assigned tasks (claimed, in_progress) using /tasks/my # Get assigned tasks using /tasks/my
# Includes: PM-assigned pending tasks + tasks being actively worked on
assigned_resp = await client.get( assigned_resp = await client.get(
f"{settings.internal_api_url}/tasks/my", f"{settings.internal_api_url}/tasks/my",
headers=headers, headers=headers,
@@ -226,27 +228,55 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
if assigned_resp.status_code == status.HTTP_200_OK if assigned_resp.status_code == status.HTTP_200_OK
else [] else []
) )
# Include pending tasks (PM assigned) + active work statuses
assigned_tasks = [ assigned_tasks = [
t t
for t in assigned_data for t in assigned_data
if t.get("status") if t.get("status")
in ["claimed", "in_progress", "verifying", "needs_revision"] in ["pending", "claimed", "in_progress", "verifying", "needs_revision"]
] ]
# Get available tasks (pending, team pool) # Get available tasks based on agent role
params: dict[str, Any] = {"status": "pending"} # QA agents need awaiting_qa tasks, Documenters need awaiting_documentation
if team: agent_role = get_agent_role(agent_id)
params["team"] = team
available_resp = await client.get( available_tasks: list[dict[str, Any]] = []
f"{settings.internal_api_url}/tasks",
params=params, if agent_role == "qa":
headers=headers, # QA agents look for tasks awaiting QA review
) qa_resp = await client.get(
available_tasks = ( f"{settings.internal_api_url}/tasks/awaiting-qa",
available_resp.json() params={"team": team} if team else {},
if available_resp.status_code == status.HTTP_200_OK headers=headers,
else [] )
) if qa_resp.status_code == status.HTTP_200_OK:
available_tasks = qa_resp.json()
elif agent_role == "documenter":
# Documenters look for tasks awaiting documentation
doc_resp = await client.get(
f"{settings.internal_api_url}/tasks/awaiting-docs",
params={"team": team} if team else {},
headers=headers,
)
if doc_resp.status_code == status.HTTP_200_OK:
available_tasks = doc_resp.json()
else:
# Developers and PMs look for pending tasks
params: dict[str, Any] = {"status": "pending"}
if team:
params["team"] = team
pending_resp = await client.get(
f"{settings.internal_api_url}/tasks",
params=params,
headers=headers,
)
if pending_resp.status_code == status.HTTP_200_OK:
available_tasks = pending_resp.json()
# Filter out tasks already in assigned_tasks from available_tasks
# (prevents PM-assigned pending tasks from appearing in both lists)
assigned_ids = {t.get("id") for t in assigned_tasks}
available_tasks = [t for t in available_tasks if t.get("id") not in assigned_ids]
# Determine guidance # Determine guidance
if paused_tasks: if paused_tasks:
@@ -939,13 +969,12 @@ async def _handle_task_submit_qa(
"Can only submit verified tasks for QA", "Can only submit verified tasks for QA",
) )
# Update with notes # Update with notes - combine dev_notes and handoff summary
# (handoff summary goes into dev_notes for documenter to read)
combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}"
await client.patch( await client.patch(
f"{settings.internal_api_url}/tasks/{task_id}", f"{settings.internal_api_url}/tasks/{task_id}",
json={ json={"dev_notes": combined_notes},
"dev_notes": dev_notes,
"documenter_handoff": handoff_summary,
},
headers=headers, headers=headers,
) )
@@ -1001,12 +1030,12 @@ async def _handle_task_qa_pass(
# Check QA is not reviewing own work # Check QA is not reviewing own work
# Check against original developer stored in quick_context # Check against original developer stored in quick_context
quick_context = task.get("quick_context") or "" quick_context = task.get("quick_context")
original_dev = None original_dev = extract_original_developer(quick_context)
if quick_context.startswith("original_developer:"):
original_dev = quick_context.split(":", 1)[1]
if original_dev and agent_id == original_dev: # Resolve agent_id to UUID for proper comparison
agent_uuid = await _resolve_agent_uuid(agent_id, headers)
if original_dev and agent_uuid and agent_uuid == original_dev:
return _format_error_response( return _format_error_response(
"SELF_REVIEW", "SELF_REVIEW",
"Cannot review your own work.", "Cannot review your own work.",
+34 -11
View File
@@ -199,6 +199,13 @@ class AgentOrchestrator:
"mcp__roboco-notify__*", "mcp__roboco-notify__*",
# Journal - always needed for reflection # Journal - always needed for reflection
"mcp__roboco-journal__*", "mcp__roboco-journal__*",
# File operations for documenters and developers
"Write(path:/app/docs/**)",
"Write(path:/app/CHANGELOG.md)",
"Write(path:/app/README.md)",
"Edit(path:/app/docs/**)",
"Edit(path:/app/CHANGELOG.md)",
"Edit(path:/app/README.md)",
] ]
# Path to agent Claude settings (shared across all agents) # Path to agent Claude settings (shared across all agents)
@@ -1037,16 +1044,34 @@ Start by:
# ========================================================================= # =========================================================================
# Keywords that indicate strategic/board-level tasks # Keywords that indicate strategic/board-level tasks
_BOARD_KEYWORDS = frozenset({ _BOARD_KEYWORDS = frozenset(
"roadmap", "architecture", "security", "budget", "hiring", {
"strategy", "vision", "milestone", "release", "launch", "roadmap",
}) "architecture",
"security",
"budget",
"hiring",
"strategy",
"vision",
"milestone",
"release",
"launch",
}
)
# Keywords that indicate PM coordination is needed # Keywords that indicate PM coordination is needed
_PM_KEYWORDS = frozenset({ _PM_KEYWORDS = frozenset(
"coordinate", "integration", "cross-team", "sync", {
"planning", "milestone", "dependencies", "review", "coordinate",
}) "integration",
"cross-team",
"sync",
"planning",
"milestone",
"dependencies",
"review",
}
)
def _classify_task_routing(self, task: dict[str, Any]) -> str: def _classify_task_routing(self, task: dict[str, Any]) -> str:
""" """
@@ -1371,9 +1396,7 @@ Start now: roboco_task_get("{task_id}")
continue # Not a parent task continue # Not a parent task
# Check if all subtasks are completed # Check if all subtasks are completed
all_completed = all( all_completed = all(st.get("status") == "completed" for st in subtasks)
st.get("status") == "completed" for st in subtasks
)
if not all_completed: if not all_completed:
continue # Not ready for closure continue # Not ready for closure
+18 -2
View File
@@ -654,17 +654,33 @@ class JournalService:
entry_id = UUID(entry_id_str) entry_id = UUID(entry_id_str)
entry = await self.get_entry(entry_id) entry = await self.get_entry(entry_id)
if entry: if entry:
entries.append(entry) # Filter by agent_id to ensure we only
# return this agent's entries
journal = await self.get_journal(agent_id)
if journal and entry.journal_id == journal.id:
entries.append(entry)
except ValueError: except ValueError:
logger.warning( logger.warning(
"Invalid entry_id in search result", "Invalid entry_id in search result",
entry_id=entry_id_str, entry_id=entry_id_str,
) )
logger.debug(
"Journal search completed",
agent_id=str(agent_id),
query=query[:50],
results_found=len(entries),
)
return entries[:top_k] return entries[:top_k]
except Exception as e: except Exception as e:
logger.warning("Journal search failed", error=str(e)) logger.error(
"Journal search failed",
agent_id=str(agent_id),
query=query[:50] if query else "empty",
error=str(e),
error_type=type(e).__name__,
)
return [] return []
+59 -18
View File
@@ -13,18 +13,52 @@ import structlog
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import AgentTable, HandoffTable, TaskTable from roboco.db.tables import AgentTable, TaskTable
from roboco.enforcement import ( from roboco.enforcement import (
TaskLifecycleError, TaskLifecycleError,
TaskOwnershipError, TaskOwnershipError,
validate_task_ownership, validate_task_ownership,
validate_task_transition, validate_task_transition,
) )
from roboco.models.base import HandoffStatus, TaskStatus, Team from roboco.models.base import TaskStatus, Team
from roboco.models.task import TaskCreateRequest from roboco.models.task import TaskCreateRequest
logger = structlog.get_logger() logger = structlog.get_logger()
# UUID format constants for validation
_UUID_LENGTH = 36 # Standard UUID string length
_UUID_HYPHEN_COUNT = 4 # Number of hyphens in a UUID
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: class TaskService:
""" """
@@ -152,12 +186,24 @@ class TaskService:
valid_statuses.add(TaskStatus.CLAIMED) valid_statuses.add(TaskStatus.CLAIMED)
# Role-based claiming: QA and Documenters can claim specific statuses # Role-based claiming: QA and Documenters can claim specific statuses
# If role is missing but task requires specific role, reject the claim
if agent and agent.role: if agent and agent.role:
role = agent.role.value if hasattr(agent.role, "value") else str(agent.role) role = agent.role.value if hasattr(agent.role, "value") else str(agent.role)
if role == "qa": if role == "qa":
valid_statuses.add(TaskStatus.AWAITING_QA) valid_statuses.add(TaskStatus.AWAITING_QA)
elif role == "documenter": elif role == "documenter":
valid_statuses.add(TaskStatus.AWAITING_DOCUMENTATION) valid_statuses.add(TaskStatus.AWAITING_DOCUMENTATION)
elif task.status in {TaskStatus.AWAITING_QA, TaskStatus.AWAITING_DOCUMENTATION}:
# No role information - reject claims for role-specific statuses
logger.warning(
"Cannot claim task - role required for this status",
task_id=str(task_id),
task_status=task.status.value,
agent_id=str(agent_id),
has_agent=agent is not None,
agent_role="none",
)
return None
if task.status not in valid_statuses: if task.status not in valid_statuses:
logger.warning( logger.warning(
@@ -431,23 +477,18 @@ class TaskService:
): ):
return None return None
# Enforce handoff requirement for tasks that went through full lifecycle # Enforce lifecycle: tasks awaiting documentation must have passed QA
if task.status == TaskStatus.AWAITING_DOCUMENTATION and not skip_handoff_check: if (
handoff_result = await self.session.execute( task.status == TaskStatus.AWAITING_DOCUMENTATION
select(HandoffTable).where( and not skip_handoff_check
HandoffTable.task_id == task_id, and not task.qa_verified
HandoffTable.status == HandoffStatus.ACCEPTED, ):
) logger.warning(
"Cannot complete task - QA verification required",
task_id=str(task_id),
status=task.status.value,
) )
handoff = handoff_result.scalar_one_or_none() return None
if not handoff:
logger.warning(
"Cannot complete task - handoff required",
task_id=str(task_id),
status=task.status.value,
)
return None
task.completed_at = datetime.now(UTC) task.completed_at = datetime.now(UTC)
task.status = TaskStatus.COMPLETED task.status = TaskStatus.COMPLETED