mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Improved workflow and task lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user