diff --git a/docker-compose.yml b/docker-compose.yml index 7ee4d62f..a160d446 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,9 @@ services: # ========================================================================== # PostgreSQL - Primary Database with pgvector for RAG - # Uses Docker Hardened Image (DHI) base with pgvector extension # ========================================================================== postgres: - build: - context: . - dockerfile: docker/postgres-pgvector.Dockerfile - image: roboco-postgres-pgvector + image: pgvector/pgvector:pg16 container_name: roboco-postgres restart: unless-stopped environment: @@ -26,10 +22,9 @@ services: # ========================================================================== # Redis - Cache, Sessions, Event Bus - # Uses Docker Hardened Image (DHI) for enhanced security # ========================================================================== redis: - image: dhi.io/redis:8-alpine + image: redis:8-alpine container_name: roboco-redis restart: unless-stopped command: redis-server --appendonly yes diff --git a/docker/agent-base.Dockerfile b/docker/agent-base.Dockerfile index 7d023f75..4df96812 100644 --- a/docker/agent-base.Dockerfile +++ b/docker/agent-base.Dockerfile @@ -1,10 +1,10 @@ # ============================================================================= -# Agent Base Image - Docker Hardened Image (DHI) +# Agent Base Image # ============================================================================= -# Uses DHI Python 3.13 with dev tools for Claude Code agent containers +# Python 3.13 with dev tools for Claude Code agent containers # ============================================================================= -FROM dhi.io/python:3.13-debian13-dev +FROM python:3.13-bookworm # Install Node.js 22 (required for Claude Code CLI) RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/docker/orchestrator.Dockerfile b/docker/orchestrator.Dockerfile index 1025b098..c26015e4 100644 --- a/docker/orchestrator.Dockerfile +++ b/docker/orchestrator.Dockerfile @@ -1,10 +1,10 @@ # ============================================================================= -# Orchestrator - Docker Hardened Image (DHI) +# Orchestrator # ============================================================================= -# API Server + Agent Spawner using DHI Python 3.13 +# API Server + Agent Spawner using Python 3.13 # ============================================================================= -FROM dhi.io/python:3.13-debian13-dev +FROM python:3.13-bookworm # Install dependencies + Docker CLI RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/docker/postgres-pgvector.Dockerfile b/docker/postgres-pgvector.Dockerfile index 0ec61349..5b86862b 100644 --- a/docker/postgres-pgvector.Dockerfile +++ b/docker/postgres-pgvector.Dockerfile @@ -1,44 +1,14 @@ # ============================================================================= -# PostgreSQL with pgvector on Docker Hardened Image +# PostgreSQL with pgvector (Custom Build Example) # ============================================================================= -# Multi-stage build: -# 1. Build pgvector extension using standard postgres image (has build tools) -# 2. Copy compiled extension to DHI postgres (minimal, secure runtime) +# Example: Build pgvector from source on standard postgres image +# Currently unused - docker-compose.yml uses pgvector/pgvector:pg16 directly # ============================================================================= -# ----------------------------------------------------------------------------- -# Stage 1: Build pgvector extension -# ----------------------------------------------------------------------------- -FROM postgres:17 AS builder - -# Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - git \ - postgresql-server-dev-17 \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -# Clone and build pgvector (use tagged release for reproducibility) -ARG PGVECTOR_VERSION=0.8.1 -RUN git clone --branch v${PGVECTOR_VERSION} --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector \ - && cd /tmp/pgvector \ - && make OPTFLAGS="" \ - && make install - -# ----------------------------------------------------------------------------- -# Stage 2: DHI Runtime with pgvector -# ----------------------------------------------------------------------------- -FROM dhi.io/postgres:17-debian13 - -# Copy pgvector extension files from builder -# Extension shared library -COPY --from=builder /usr/lib/postgresql/17/lib/vector.so /usr/lib/postgresql/17/lib/ -# Extension control and SQL files -COPY --from=builder /usr/share/postgresql/17/extension/vector* /usr/share/postgresql/17/extension/ +FROM pgvector/pgvector:pg17 # Add init script to create extension on startup COPY docker/postgres-init/01-create-extensions.sql /docker-entrypoint-initdb.d/ -LABEL org.opencontainers.image.title="PostgreSQL with pgvector (DHI)" -LABEL org.opencontainers.image.description="Docker Hardened PostgreSQL 17 with pgvector extension for vector similarity search" +LABEL org.opencontainers.image.title="PostgreSQL with pgvector" +LABEL org.opencontainers.image.description="PostgreSQL 17 with pgvector extension for vector similarity search" diff --git a/roboco/agents/documenter.py b/roboco/agents/documenter.py index 4cb389b2..46abec7a 100644 --- a/roboco/agents/documenter.py +++ b/roboco/agents/documenter.py @@ -291,9 +291,17 @@ Respond with structured analysis. # PLAN: Save documentation plan to task API (required before start) plan_data = { "approach": f"Document {ctx.title}", - "steps": [doc.title for doc in ctx.documents_needed], + "sub_tasks": [ + { + "id": f"doc-{i}", + "title": doc.title, + "description": f"Write {doc.doc_type.value} at {doc.path}", + "completed": False, + "order": i, + } + for i, doc in enumerate(ctx.documents_needed) + ], "risks": [], - "estimated_sessions": 1, } await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data}) diff --git a/roboco/agents/factories/developers.py b/roboco/agents/factories/developers.py index 3374a606..1ef49772 100644 --- a/roboco/agents/factories/developers.py +++ b/roboco/agents/factories/developers.py @@ -23,11 +23,31 @@ _DEFAULT_PROMPTS = { Team.UX_UI: "You are a UX/UI developer.", } -# Default capabilities for each team +# Default capabilities for each team (matches blueprint capabilities) _CAPABILITIES = { - Team.BACKEND: ["code_execution", "git_operations", "file_management"], - Team.FRONTEND: ["code_execution", "git_operations", "file_management"], - Team.UX_UI: ["design_tools", "file_management"], + Team.BACKEND: [ + "code_execution", + "git_operations", + "file_management", + "api_development", + "database_design", + ], + Team.FRONTEND: [ + "code_execution", + "git_operations", + "file_management", + "browser_testing", + "accessibility_testing", + "responsive_design", + ], + Team.UX_UI: [ + "design_tools", + "file_management", + "figma_expertise", + "prototyping", + "design_system_management", + "accessibility_design", + ], } diff --git a/roboco/agents/factories/documenters.py b/roboco/agents/factories/documenters.py index d956e1ef..ae68f3fe 100644 --- a/roboco/agents/factories/documenters.py +++ b/roboco/agents/factories/documenters.py @@ -23,11 +23,27 @@ _DEFAULT_PROMPTS = { Team.UX_UI: "You are a UX/UI documenter.", } -# Default capabilities for each team +# Default capabilities for each team (matches blueprint capabilities) _CAPABILITIES = { - Team.BACKEND: ["documentation", "file_management"], - Team.FRONTEND: ["documentation", "storybook", "file_management"], - Team.UX_UI: ["documentation", "design_system_docs"], + Team.BACKEND: [ + "technical_writing", + "api_documentation", + "code_reading", + "file_management", + ], + Team.FRONTEND: [ + "technical_writing", + "component_documentation", + "code_reading", + "storybook", + "file_management", + ], + Team.UX_UI: [ + "design_documentation", + "design_system_maintenance", + "technical_writing", + "file_management", + ], } diff --git a/roboco/agents/factories/qa.py b/roboco/agents/factories/qa.py index e2ca2935..5ebe4a2e 100644 --- a/roboco/agents/factories/qa.py +++ b/roboco/agents/factories/qa.py @@ -23,11 +23,25 @@ _DEFAULT_PROMPTS = { Team.UX_UI: "You are a UX/UI QA engineer.", } -# Default capabilities for each team +# Default capabilities for each team (matches blueprint capabilities) _CAPABILITIES = { - Team.BACKEND: ["code_review", "test_execution", "security_analysis"], - Team.FRONTEND: ["visual_testing", "a11y_testing", "browser_testing"], - Team.UX_UI: ["design_review", "consistency_check", "a11y_testing"], + Team.BACKEND: [ + "code_review", + "test_execution", + "security_analysis", + "quality_assurance", + ], + Team.FRONTEND: [ + "visual_testing", + "accessibility_testing", + "browser_testing", + "quality_assurance", + ], + Team.UX_UI: [ + "design_review", + "accessibility_review", + "quality_assurance", + ], } diff --git a/roboco/agents/pm.py b/roboco/agents/pm.py index f4dde0dc..aa737fa3 100644 --- a/roboco/agents/pm.py +++ b/roboco/agents/pm.py @@ -311,34 +311,121 @@ class CellPMAgent(Agent, CyclicPhaseRunner[CellPMPhase]): } async def _delegate_task(self, task_id: UUID, task: dict[str, Any]) -> bool: - """Delegate task by creating subtasks for developers.""" - # Simple delegation - create one subtask assigned to available dev + """Delegate task by creating subtasks for developers. + + Follows blueprint workflow: + CREATE (backlog) → SESSION → ACTIVATE (pending) → NOTIFY + """ + # Find available developer best_dev = await self._find_best_dev(task_id) if not best_dev: self.log.warning("No available developer for task", task_id=str(task_id)) return False - # Create subtask - await self._api_call( + team = self.team.value if self.team else "backend" + + # Step 1: Create subtask with status "backlog" (prevents premature pickup) + subtask_resp = await self._api_call( "POST", "/tasks", json={ "title": f"Implement: {task.get('title', 'Task')}", "description": task.get("description", ""), - "team": self.team.value if self.team else "backend", + "team": team, "acceptance_criteria": task.get("acceptance_criteria", []), "parent_task_id": str(task_id), "assigned_to": str(best_dev.agent_id), - "status": "pending", + "status": "backlog", # Backlog until session is ready }, ) + subtask_id = subtask_resp.get("id") + if not subtask_id: + self.log.error("Failed to create subtask", parent_task_id=str(task_id)) + return False + self.log.info( - "PM created subtask", + "PM created subtask (backlog)", + subtask_id=subtask_id, parent_task_id=str(task_id), assigned_to=str(best_dev.agent_id), ) + + # Step 2: Create session for the subtask + channel_slug = self._get_team_channel(team) + try: + await self._api_call( + "POST", + "/sessions/for-tasks", + json={ + "task_ids": [subtask_id], + "channel_slug": channel_slug, + "scope": f"Work session for {task.get('title', 'task')}", + "relationship_type": "implements", + }, + ) + self.log.info("Session created for subtask", subtask_id=subtask_id) + except Exception as e: + self.log.warning( + "Session creation failed, activating anyway", + subtask_id=subtask_id, + error=str(e), + ) + + # Step 3: Activate subtask (changes status to pending) + try: + await self._api_call("POST", f"/tasks/{subtask_id}/activate") + self.log.info("Subtask activated", subtask_id=subtask_id) + except Exception as e: + self.log.error( + "Failed to activate subtask", + subtask_id=subtask_id, + error=str(e), + ) + return False + + # Step 4: Notify assigned developer + try: + await self._notify_developer(best_dev, subtask_id, task) + except Exception as e: + self.log.warning( + "Failed to notify developer (task still assigned)", + error=str(e), + ) + return True + def _get_team_channel(self, team: str) -> str: + """Get the channel slug for a team.""" + channel_map = { + "backend": "backend-cell", + "frontend": "frontend-cell", + "ux_ui": "uxui-cell", + } + return channel_map.get(team, "backend-cell") + + async def _notify_developer( + self, dev: Any, subtask_id: str, task: dict[str, Any] + ) -> None: + """Notify developer of new task assignment.""" + await self._api_call( + "POST", + "/notifications", + json={ + "type": "task_assigned", + "priority": "normal", + "to_agents": [str(dev.agent_id)], + "subject": f"New task: {task.get('title', 'Task')}", + "body": ( + f"You've been assigned a new task.\n\n" + f"Task ID: {subtask_id}\n" + f"Title: {task.get('title', 'Task')}\n\n" + f"Use roboco_task_scan to find and claim it." + ), + "related_task_id": subtask_id, + "requires_ack": False, + }, + ) + # ========================================================================= # CELL PM PHASES # ========================================================================= @@ -445,11 +532,15 @@ medium,TASK-abc123,P1,backend-dev-1 # Check for pending questions in channel questions = await self._get_pending_questions() - for question in questions: + for question_data in questions: + question_content = question_data.get("content", "") + task_id = question_data.get("task_id") + session_id = question_data.get("session_id") + # Use TOON for token-efficient context encoding question_context = self.format_context_labeled( "Cell Question", - {"question": question, "cell": self.cell_name}, + {"question": question_content, "cell": self.cell_name}, ) prompt = f"""A cell member needs help: @@ -464,12 +555,26 @@ As the Cell PM, provide: Be helpful and unblock the team. """ response = await self.think(prompt) - # TODO: Questions should include task_id for routing - # For now, log that we can't route without session context - self.log.info( - "PM response (no session context - need task_id in questions)", - response_preview=response[:100], - ) + + # Send response with task_id for proper routing + if session_id: + await self.send_message( + UUID(session_id), + response, + message_type="answer", + task_id=UUID(task_id) if task_id else None, + ) + self.log.info( + "PM responded to question", + task_id=task_id, + response_preview=response[:100], + ) + else: + self.log.info( + "PM response (no session - using channel)", + task_id=task_id, + response_preview=response[:100], + ) async def _phase_escalate(self) -> None: """ @@ -626,15 +731,26 @@ Be helpful and unblock the team. except Exception as e: self.log.error("Failed to assign task", error=str(e)) - async def _get_pending_questions(self) -> list[str]: - """Get unanswered questions from channel.""" + async def _get_pending_questions(self) -> list[dict[str, Any]]: + """Get unanswered questions from channel. + + Returns full message info including task_id for routing. + """ try: result = await self._api_call( "GET", "/messages", params={"message_type": "dialogue", "unanswered": True}, ) - return [m.get("content", "") for m in result.get("items", [])] + return [ + { + "content": m.get("content", ""), + "task_id": m.get("task_id"), + "session_id": m.get("session_id"), + "from_agent": m.get("from_agent"), + } + for m in result.get("items", []) + ] except Exception as e: self.log.warning("Failed to get pending questions", error=str(e)) return [] @@ -1073,8 +1189,13 @@ class MainPMAgent(Agent, CyclicPhaseRunner[MainPMPhase]): async def _create_cell_task( self, parent_id: UUID, task: dict[str, Any], team: str, pm_slug: str ) -> None: - """Create a task for a Cell PM.""" - await self._api_call( + """Create a task for a Cell PM. + + Follows blueprint workflow: + CREATE (backlog) → GROUP → SESSION → ACTIVATE (pending) → NOTIFY + """ + # Step 1: Create task with status "backlog" + task_resp = await self._api_call( "POST", "/tasks", json={ @@ -1084,16 +1205,83 @@ class MainPMAgent(Agent, CyclicPhaseRunner[MainPMPhase]): "acceptance_criteria": task.get("acceptance_criteria", []), "parent_task_id": str(parent_id), "assigned_to": pm_slug, - "status": "pending", + "status": "backlog", # Backlog until session is ready }, ) + cell_task_id = task_resp.get("id") + if not cell_task_id: + self.log.error("Failed to create cell task", parent_id=str(parent_id)) + return + self.log.info( - "Main PM created cell task", + "Main PM created cell task (backlog)", + cell_task_id=cell_task_id, parent_task_id=str(parent_id), team=team, assigned_to=pm_slug, ) + # Step 2: Create group if needed (cross-cell initiatives) + channel_slug = self._get_team_channel(team) + + # Step 3: Create session for the cell task + try: + await self._api_call( + "POST", + "/sessions/for-tasks", + json={ + "task_ids": [cell_task_id], + "channel_slug": channel_slug, + "scope": f"Cell work for {task.get('title', 'initiative')}", + "relationship_type": "implements", + }, + ) + self.log.info("Session created for cell task", cell_task_id=cell_task_id) + except Exception as e: + self.log.warning( + "Session creation failed, activating anyway", + cell_task_id=cell_task_id, + error=str(e), + ) + + # Step 4: Activate task (changes status to pending) + try: + await self._api_call("POST", f"/tasks/{cell_task_id}/activate") + self.log.info("Cell task activated", cell_task_id=cell_task_id) + except Exception as e: + self.log.error( + "Failed to activate cell task", + cell_task_id=cell_task_id, + error=str(e), + ) + return + + # Step 5: Notify Cell PM + try: + await self._api_call( + "POST", + "/notifications", + json={ + "type": "task_assigned", + "priority": "normal", + "to_agents": [pm_slug], + "subject": f"New initiative: {task.get('title', 'Task')}", + "body": ( + f"A new initiative has been assigned to your cell.\n\n" + f"Task ID: {cell_task_id}\n" + f"Title: {task.get('title', 'Task')}\n\n" + f"Please triage and delegate to your team." + ), + "related_task_id": cell_task_id, + "requires_ack": True, + }, + ) + except Exception as e: + self.log.warning( + "Failed to notify Cell PM (task still assigned)", + error=str(e), + ) + # ========================================================================= # MAIN PM PHASES # ========================================================================= diff --git a/roboco/agents/qa.py b/roboco/agents/qa.py index 3d17461a..f5ab1a5d 100644 --- a/roboco/agents/qa.py +++ b/roboco/agents/qa.py @@ -271,9 +271,17 @@ Acceptance Criteria,Verify all criteria met,Review implementation|Check each cri # PLAN: Save test plan to task API (required before start) plan_data = { "approach": f"QA review of {ctx.title}", - "steps": [tc.name for tc in ctx.test_cases], + "sub_tasks": [ + { + "id": f"test-{i}", + "title": tc.name, + "description": tc.description, + "completed": False, + "order": i, + } + for i, tc in enumerate(ctx.test_cases) + ], "risks": [], - "estimated_sessions": 1, } await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data}) diff --git a/roboco/api/app.py b/roboco/api/app.py index 5bd7341e..9e8e33b3 100644 --- a/roboco/api/app.py +++ b/roboco/api/app.py @@ -15,6 +15,7 @@ from roboco.api.middleware import setup_middleware from roboco.api.routes.agents import router as agents_router from roboco.api.routes.channels import router as channels_router from roboco.api.routes.dashboard import router as dashboard_router +from roboco.api.routes.groups import router as groups_router from roboco.api.routes.health import router as health_router from roboco.api.routes.journals import router as journals_router from roboco.api.routes.kanban import router as kanban_router @@ -159,6 +160,12 @@ def create_app() -> FastAPI: tags=["Channels"], ) + app.include_router( + groups_router, + prefix=f"{api_prefix}/groups", + tags=["Groups"], + ) + app.include_router( sessions_router, prefix=f"{api_prefix}/sessions", diff --git a/roboco/mcp/notify_server.py b/roboco/mcp/notify_server.py index 2989f9e7..5bac6d25 100644 --- a/roboco/mcp/notify_server.py +++ b/roboco/mcp/notify_server.py @@ -240,24 +240,59 @@ def _check_recipients(agent_id: str, recipients: list[str]) -> dict[str, Any] | return None +def _validate_send_input( + agent_id: str, data: SendNotificationInput +) -> dict[str, Any] | None: + """Validate all send notification inputs. Returns error or None.""" + for check in [ + lambda: _check_send_permission(agent_id), + lambda: _check_recipients(agent_id, data.recipients), + lambda: _validate_notification_type(data.notification_type), + lambda: _validate_priority(data.priority), + ]: + if error := check(): + return error + return None + + +async def _resolve_recipients( + recipients: list[str], client: "ApiClient" +) -> tuple[list[str], dict[str, Any] | None]: + """Resolve recipient slugs to UUIDs. Returns (resolved_list, error_or_none).""" + from roboco.mcp.utils import resolve_agent_uuid_cached + + resolved: list[str] = [] + unresolved: list[str] = [] + for recipient in recipients: + uuid = await resolve_agent_uuid_cached(recipient, client) + if uuid: + resolved.append(uuid) + else: + unresolved.append(recipient) + + if unresolved: + return [], format_error_response( + "RECIPIENT_NOT_FOUND", + f"Could not resolve recipient(s): {', '.join(unresolved)}", + ) + return resolved, None + + async def _handle_send( client: ApiClient, agent_id: str, data: SendNotificationInput ) -> dict[str, Any]: """Handle sending a notification.""" - # Validate permissions and data - if error := _check_send_permission(agent_id): + if error := _validate_send_input(agent_id, data): return error - if error := _check_recipients(agent_id, data.recipients): - return error - if error := _validate_notification_type(data.notification_type): - return error - if error := _validate_priority(data.priority): + + resolved_recipients, error = await _resolve_recipients(data.recipients, client) + if error: return error payload = { "type": data.notification_type, "priority": data.priority, - "to_agents": data.recipients, + "to_agents": resolved_recipients, "subject": data.subject, "body": data.body, "requires_ack": data.requires_ack, diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 1b8fd26c..bdbf3acb 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -1418,16 +1418,42 @@ Start now: roboco_task_get("{task_id}") This is the FIRST dispatcher called - it classifies unassigned tasks and routes them to Board, Main PM, Cell PM, or directly to devs. + Also handles already-assigned pending tasks for PM agents. - Monitors: pending tasks with no assigned_to + Monitors: pending tasks (both assigned and unassigned) Spawns: product-owner, main-pm, be-pm, fe-pm, ux-pm (or devs for simple) """ - # Get pending tasks that haven't been assigned yet + # Get pending tasks tasks = await self._fetch_tasks(client, "pending") + # PM-level agents that can have direct assignments + pm_agents = { + "main-pm", + "be-pm", + "fe-pm", + "ux-pm", + "product-owner", + "head-marketing", + "auditor", + } + for task in tasks: - # Skip already assigned tasks - if task.get("assigned_to"): + assigned_to = task.get("assigned_to") + + # Handle already-assigned tasks for PM agents + if assigned_to: + agent_slug = self._resolve_agent_slug(assigned_to) + if agent_slug in pm_agents and not self._is_agent_active(agent_slug): + logger.info( + "Spawning assigned PM agent", + task_id=task.get("id"), + agent_id=agent_slug, + ) + await self.spawn_agent( + agent_id=agent_slug, + task_id=task["id"], + initial_prompt=self._build_pm_triage_prompt(task), + ) continue # Classify the task