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
**File Paths** - Write documentation to `/app/docs/`:
- `/app/docs/backend/` - Backend documentation
- `/app/docs/api/` - API documentation
- `/app/docs/changelog.md` - Changelog
- `/app/docs/backend/api/` - API documentation
- `/app/docs/backend/changelog.md` - Changelog
**API Documentation** (if new/changed endpoints)
- 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?
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.
```
(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
**File Paths** - Write documentation to `/app/docs/`:
- `/app/docs/frontend/` - Frontend documentation
- `/app/docs/components/` - Component documentation
- `/app/docs/changelog.md` - Changelog
- `/app/docs/frontend/components/` - Component documentation
- `/app/docs/frontend/changelog.md` - Changelog
**Component Documentation**
- Props interface
+2 -2
View File
@@ -85,8 +85,8 @@ If none: `roboco_agent_idle()`
### 6. WRITE
**File Paths** - Write documentation to `/app/docs/`:
- `/app/docs/ux_ui/` - UX/UI documentation
- `/app/docs/design-system/` - Design system documentation
- `/app/docs/changelog.md` - Changelog
- `/app/docs/ux_ui/design-system/` - Design system documentation
- `/app/docs/ux_ui/changelog.md` - Changelog
**Component Guidelines**
- When to use this component
+2 -2
View File
@@ -259,7 +259,7 @@ Respond with structured analysis.
DocumentSpec(
doc_type=DocType.API,
title=f"API documentation for {ctx.title}",
path="docs/api/",
path="docs/backend/api/",
priority="required",
)
)
@@ -270,7 +270,7 @@ Respond with structured analysis.
DocumentSpec(
doc_type=DocType.COMPONENT,
title=f"Component documentation for {ctx.title}",
path="docs/components/",
path="docs/frontend/components/",
priority="required",
)
)
+1 -1
View File
@@ -176,7 +176,7 @@ async def send_message(
content=data.content,
message_type=data.type,
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,
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.services.audit import get_audit_service
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()
@@ -714,9 +718,7 @@ async def pass_qa(
# QA cannot review their own tasks (prevent self-review)
# Check against original developer stored in quick_context, not current assigned_to
original_dev = None
if task.quick_context and task.quick_context.startswith("original_developer:"):
original_dev = task.quick_context.split(":", 1)[1]
original_dev = extract_original_developer(task.quick_context)
if original_dev and str(agent.agent_id) == original_dev:
audit = get_audit_service()
@@ -767,9 +769,7 @@ async def fail_qa(
# QA cannot review their own tasks (prevent self-review)
# Check against original developer stored in quick_context, not current assigned_to
original_dev = None
if task.quick_context and task.quick_context.startswith("original_developer:"):
original_dev = task.quick_context.split(":", 1)[1]
original_dev = extract_original_developer(task.quick_context)
if original_dev and str(agent.agent_id) == original_dev:
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": ["in_progress", "cancelled"],
# Awaiting QA - can pass (to docs) or fail (needs revision) or cancel
"awaiting_qa": ["awaiting_documentation", "needs_revision", "cancelled"],
# Awaiting QA - can pass (to docs), fail (needs revision), block, or cancel
"awaiting_qa": ["awaiting_documentation", "needs_revision", "blocked", "cancelled"],
# Awaiting documentation - can complete or cancel
"awaiting_documentation": ["completed", "cancelled"],
# Terminal states - cannot transition out
+1 -1
View File
@@ -430,7 +430,7 @@ async def _handle_message_send(
"content": data.content,
"is_reply": data.reply_to is not None,
"reply_to": data.reply_to,
"mentions": resolved_mentions if resolved_mentions else None,
"mentions": resolved_mentions,
"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.llm import ToonAdapter
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]:
@@ -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 []
)
# 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(
f"{settings.internal_api_url}/tasks/my",
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
else []
)
# Include pending tasks (PM assigned) + active work statuses
assigned_tasks = [
t
for t in assigned_data
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)
params: dict[str, Any] = {"status": "pending"}
if team:
params["team"] = team
available_resp = await client.get(
f"{settings.internal_api_url}/tasks",
params=params,
headers=headers,
)
available_tasks = (
available_resp.json()
if available_resp.status_code == status.HTTP_200_OK
else []
)
# Get available tasks based on agent role
# QA agents need awaiting_qa tasks, Documenters need awaiting_documentation
agent_role = get_agent_role(agent_id)
available_tasks: list[dict[str, Any]] = []
if agent_role == "qa":
# QA agents look for tasks awaiting QA review
qa_resp = await client.get(
f"{settings.internal_api_url}/tasks/awaiting-qa",
params={"team": team} if team else {},
headers=headers,
)
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
if paused_tasks:
@@ -939,13 +969,12 @@ async def _handle_task_submit_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(
f"{settings.internal_api_url}/tasks/{task_id}",
json={
"dev_notes": dev_notes,
"documenter_handoff": handoff_summary,
},
json={"dev_notes": combined_notes},
headers=headers,
)
@@ -1001,12 +1030,12 @@ async def _handle_task_qa_pass(
# Check QA is not reviewing own work
# Check against original developer stored in quick_context
quick_context = task.get("quick_context") or ""
original_dev = None
if quick_context.startswith("original_developer:"):
original_dev = quick_context.split(":", 1)[1]
quick_context = task.get("quick_context")
original_dev = extract_original_developer(quick_context)
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(
"SELF_REVIEW",
"Cannot review your own work.",
+34 -11
View File
@@ -199,6 +199,13 @@ class AgentOrchestrator:
"mcp__roboco-notify__*",
# Journal - always needed for reflection
"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)
@@ -1037,16 +1044,34 @@ Start by:
# =========================================================================
# Keywords that indicate strategic/board-level tasks
_BOARD_KEYWORDS = frozenset({
"roadmap", "architecture", "security", "budget", "hiring",
"strategy", "vision", "milestone", "release", "launch",
})
_BOARD_KEYWORDS = frozenset(
{
"roadmap",
"architecture",
"security",
"budget",
"hiring",
"strategy",
"vision",
"milestone",
"release",
"launch",
}
)
# Keywords that indicate PM coordination is needed
_PM_KEYWORDS = frozenset({
"coordinate", "integration", "cross-team", "sync",
"planning", "milestone", "dependencies", "review",
})
_PM_KEYWORDS = frozenset(
{
"coordinate",
"integration",
"cross-team",
"sync",
"planning",
"milestone",
"dependencies",
"review",
}
)
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
# Check if all subtasks are completed
all_completed = all(
st.get("status") == "completed" for st in subtasks
)
all_completed = all(st.get("status") == "completed" for st in subtasks)
if not all_completed:
continue # Not ready for closure
+18 -2
View File
@@ -654,17 +654,33 @@ class JournalService:
entry_id = UUID(entry_id_str)
entry = await self.get_entry(entry_id)
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:
logger.warning(
"Invalid entry_id in search result",
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]
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 []
+59 -18
View File
@@ -13,18 +13,52 @@ import structlog
from sqlalchemy import and_, func, or_, select
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 (
TaskLifecycleError,
TaskOwnershipError,
validate_task_ownership,
validate_task_transition,
)
from roboco.models.base import HandoffStatus, TaskStatus, Team
from roboco.models.base import TaskStatus, Team
from roboco.models.task import TaskCreateRequest
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:
"""
@@ -152,12 +186,24 @@ class TaskService:
valid_statuses.add(TaskStatus.CLAIMED)
# 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:
role = agent.role.value if hasattr(agent.role, "value") else str(agent.role)
if role == "qa":
valid_statuses.add(TaskStatus.AWAITING_QA)
elif role == "documenter":
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:
logger.warning(
@@ -431,23 +477,18 @@ class TaskService:
):
return None
# Enforce handoff requirement for tasks that went through full lifecycle
if task.status == TaskStatus.AWAITING_DOCUMENTATION and not skip_handoff_check:
handoff_result = await self.session.execute(
select(HandoffTable).where(
HandoffTable.task_id == task_id,
HandoffTable.status == HandoffStatus.ACCEPTED,
)
# Enforce lifecycle: tasks awaiting documentation must have passed QA
if (
task.status == TaskStatus.AWAITING_DOCUMENTATION
and not skip_handoff_check
and not task.qa_verified
):
logger.warning(
"Cannot complete task - QA verification required",
task_id=str(task_id),
status=task.status.value,
)
handoff = handoff_result.scalar_one_or_none()
if not handoff:
logger.warning(
"Cannot complete task - handoff required",
task_id=str(task_id),
status=task.status.value,
)
return None
return None
task.completed_at = datetime.now(UTC)
task.status = TaskStatus.COMPLETED