Added tasks sequence

This commit is contained in:
Renn F
2025-12-31 21:42:58 +01:00
parent eedf06d18a
commit 3c439d4673
24 changed files with 403 additions and 84 deletions
+61 -5
View File
@@ -70,8 +70,8 @@ pnpm test
| Cache/Queue | Redis | | Cache/Queue | Redis |
| Container Runtime | Docker + Docker Compose | | Container Runtime | Docker + Docker Compose |
| Cloud LLM | Claude API (claude-opus-4-5-20251101) | | Cloud LLM | Claude API (claude-opus-4-5-20251101) |
| Local LLM | Ollama (qwen3:8b for HyDE/RAG) | | Local LLM | Ollama (gemma3:4b for HyDE/RAG) |
| Embeddings | BAAI/bge-base-en-v1.5 (768 dim) | | Embeddings | embeddinggemma:300m (768 dim) |
| Frontend | React / Next.js (future) | | Frontend | React / Next.js (future) |
## Multi-Agent Workspace Structure ## Multi-Agent Workspace Structure
@@ -327,11 +327,67 @@ ROBOCO_RAG_USE_HYBRID_SEARCH=true
# AI/LLM # AI/LLM
ROBOCO_DEFAULT_LLM_MODEL=claude-opus-4-5-20251101 ROBOCO_DEFAULT_LLM_MODEL=claude-opus-4-5-20251101
ROBOCO_DEFAULT_EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 ROBOCO_DEFAULT_EMBEDDING_MODEL=embeddinggemma:300m
ROBOCO_LOCAL_LLM_MODEL=qwen3:8b ROBOCO_LOCAL_LLM_MODEL=gemma3:4b
ROBOCO_LOCAL_LLM_BASE_URL=http://192.168.50.111:11434/v1 ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434
``` ```
## Docker Deployment
### Container Architecture
The system runs as Docker Compose services:
| Service | Purpose | Healthcheck |
|---------|---------|-------------|
| `postgres` | PostgreSQL + pgvector | `pg_isready` |
| `redis` | Cache, sessions, event bus | `redis-cli ping` |
| `ollama` | Local LLM + embeddings | `ollama list` |
| `ollama-init` | Pulls models on startup | One-shot |
| `orchestrator` | API + agent spawner | Depends on all above |
### Startup Sequence
The startup order is critical due to dependencies:
```
postgres ──┐
redis ─────┼──> ollama ──> ollama-init ──> orchestrator
│ │ │
│ │ └── Pulls embeddinggemma:300m, gemma3:4b
│ └── Healthcheck: ollama list
└── Healthcheck: pg_isready, redis-cli ping
```
**Important timing notes:**
1. `ollama-init` pulls models (~30s for embedding model, ~2min for LLM)
2. Orchestrator waits for models before starting
3. FastAPI lifespan indexes documents using Ollama (~30-60s)
4. Orchestrator polls `/health` until API is ready before starting dispatcher
### Ollama Configuration
Ollama provides two APIs:
- `/v1/*` - OpenAI-compatible API (for LLM chat/completion)
- `/api/*` - Native Ollama API (for embeddings, model management)
The embedder uses `/api/embed` endpoint with the `embeddinggemma:300m` model.
**Environment variables for Docker:**
```bash
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1 # OpenAI-compat
ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434 # Native API
```
### Common Issues
| Symptom | Cause | Fix |
|---------|-------|-----|
| `404 /api/embed` | Model not pulled | Check `docker logs roboco-ollama-init` |
| `All connection attempts failed` | API not ready | Orchestrator starts before FastAPI lifespan completes |
| Healthcheck failing | Wrong endpoint | Use `ollama list` not `curl` |
## Blueprint Reference ## Blueprint Reference
The complete system design is documented in `HOMELAB_TEAM_V0.md`, which contains: The complete system design is documented in `HOMELAB_TEAM_V0.md`, which contains:
+4 -4
View File
@@ -90,8 +90,8 @@ ROBOCO_WORKSPACES_ROOT=/data/workspaces
ROBOCO_WORKSPACE_AUTO_CLONE=true ROBOCO_WORKSPACE_AUTO_CLONE=true
# RAG/LLM # RAG/LLM
ROBOCO_LOCAL_LLM_BASE_URL=http://localhost:11434/v1 ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL=qwen3:8b ROBOCO_LOCAL_LLM_MODEL=gemma3:4b
``` ```
## Multi-Agent Workspace Structure ## Multi-Agent Workspace Structure
@@ -176,8 +176,8 @@ uv run mypy roboco/
| Vector Store | pgvector (via piragi) | | Vector Store | pgvector (via piragi) |
| Cache/Queue | Redis | | Cache/Queue | Redis |
| RAG Library | piragi | | RAG Library | piragi |
| Embeddings | BAAI/bge-base-en-v1.5 (sentence-transformers) | | Embeddings | embeddinggemma:300m (sentence-transformers) |
| Local LLM | Ollama (qwen3:8b) | | Local LLM | Ollama (gemma3:4b) |
| Cloud LLM | Claude API (Anthropic) | | Cloud LLM | Claude API (Anthropic) |
| Package Manager | uv | | Package Manager | uv |
+2
View File
@@ -323,6 +323,8 @@ def upgrade() -> None:
postgresql.ARRAY(postgresql.UUID(as_uuid=True)), postgresql.ARRAY(postgresql.UUID(as_uuid=True)),
server_default="{}", server_default="{}",
), ),
# Ordering for sibling tasks (lower = first)
sa.Column("sequence", sa.Integer(), nullable=False, server_default="0"),
sa.Column( sa.Column(
"created_at", sa.DateTime(), nullable=False, server_default=sa.func.now() "created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
), ),
+6 -3
View File
@@ -51,6 +51,7 @@ from roboco.api.schemas.git import (
from roboco.config import settings from roboco.config import settings
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.workspace import WorkspaceError, get_workspace_service from roboco.services.workspace import WorkspaceError, get_workspace_service
from roboco.utils.converters import require_uuid
router = APIRouter() router = APIRouter()
@@ -433,16 +434,18 @@ async def create_commit(
task_service = get_task_service(db) task_service = get_task_service(db)
await task_service.add_commit( await task_service.add_commit(
task_id=task_uuid, task_id=task_uuid,
commit_hash=commit_hash, hash=commit_hash,
message=data.message, # Store original message without prefix message=data.message, # Store original message without prefix
author_id=agent.agent_id, agent_id=agent.agent_id,
) )
# If task has a work session, add commit there too # If task has a work session, add commit there too
task = await task_service.get(task_uuid) task = await task_service.get(task_uuid)
if task and task.work_session_id: if task and task.work_session_id:
work_session_service = get_work_session_service(db) work_session_service = get_work_session_service(db)
await work_session_service.add_commit(task.work_session_id, commit_hash) await work_session_service.add_commit(
require_uuid(task.work_session_id), commit_hash
)
await db.commit() await db.commit()
except Exception: except Exception:
+2 -1
View File
@@ -28,6 +28,7 @@ from roboco.enforcement import (
validate_notification_permission, validate_notification_permission,
) )
from roboco.services.notification_delivery import get_notification_delivery_service from roboco.services.notification_delivery import get_notification_delivery_service
from roboco.utils.converters import require_uuid
router = APIRouter() router = APIRouter()
@@ -161,7 +162,7 @@ async def send_notification(
# Deliver notification via Redis Streams for real-time push # Deliver notification via Redis Streams for real-time push
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
return notification_to_response(notification, agent_id) return notification_to_response(notification, agent_id)
+9 -7
View File
@@ -106,6 +106,8 @@ async def create_task(
target_date=data.target_date, target_date=data.target_date,
estimated_complexity=data.estimated_complexity, estimated_complexity=data.estimated_complexity,
status=data.status, status=data.status,
sequence=data.sequence, # Task ordering within siblings
dependency_ids=data.dependency_ids, # Dependencies for claim filtering
) )
task = await service.create(req) task = await service.create(req)
await db.commit() await db.commit()
@@ -678,7 +680,7 @@ async def soft_block_task(
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver_notification(notification) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -742,7 +744,7 @@ async def unblock_task(
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -1066,7 +1068,7 @@ async def docs_complete(
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -1152,7 +1154,7 @@ async def submit_for_pm_review(
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -1377,7 +1379,7 @@ async def escalate_to_ceo(
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -1476,7 +1478,7 @@ async def ceo_reject_task(
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
@@ -1582,7 +1584,7 @@ async def escalate_task(
from roboco.services.notification_delivery import get_notification_delivery_service from roboco.services.notification_delivery import get_notification_delivery_service
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
+1 -1
View File
@@ -49,7 +49,7 @@ class ProjectResponse(BaseModel):
created_by: UUID created_by: UUID
is_active: bool is_active: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime | None = None
class Config: class Config:
"""Pydantic config.""" """Pydantic config."""
+2
View File
@@ -224,6 +224,7 @@ class TaskResponse(BaseModel):
# Status # Status
status: TaskStatus status: TaskStatus
priority: int priority: int
sequence: int # Order number within siblings
# Ownership # Ownership
team: Team team: Team
@@ -528,6 +529,7 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
acceptance_criteria=task.acceptance_criteria or [], acceptance_criteria=task.acceptance_criteria or [],
status=task.status, status=task.status,
priority=task.priority, priority=task.priority,
sequence=task.sequence,
team=task.team, team=task.team,
created_by=require_uuid(task.created_by), created_by=require_uuid(task.created_by),
assigned_to=to_python_uuid(task.assigned_to), assigned_to=to_python_uuid(task.assigned_to),
+1 -1
View File
@@ -54,7 +54,7 @@ class WorkSessionResponse(BaseModel):
# Timestamps # Timestamps
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime | None = None
class Config: class Config:
"""Pydantic config.""" """Pydantic config."""
+5
View File
@@ -187,6 +187,11 @@ class TaskTable(Base):
ARRAY(UUID(as_uuid=True)), default=list ARRAY(UUID(as_uuid=True)), default=list
) )
# Ordering (for sibling tasks under the same parent)
sequence: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, index=True
)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
+15 -1
View File
@@ -163,7 +163,12 @@ class SendNotificationInput(BaseModel):
class TaskCreateInput(BaseModel): class TaskCreateInput(BaseModel):
"""Input for creating a task (PM only).""" """Input for creating a task (PM only).
ORDERING: Use sequence and dependency_ids to control task execution order.
- sequence: Lower numbers execute first (1, 2, 3...)
- dependency_ids: Tasks that must complete before this one can be claimed
"""
title: str = Field(..., description="Task title") title: str = Field(..., description="Task title")
description: str = Field(..., description="Task description") description: str = Field(..., description="Task description")
@@ -183,6 +188,15 @@ class TaskCreateInput(BaseModel):
default="backlog", default="backlog",
description="Status: 'backlog' (default) or 'pending' (ready for work)", description="Status: 'backlog' (default) or 'pending' (ready for work)",
) )
# Task ordering - IMPORTANT for subtask sequencing
sequence: int = Field(
default=0,
description="Execution order within siblings (lower = first). E.g., 1, 2, 3",
)
dependency_ids: list[str] = Field(
default_factory=list,
description="Task IDs that must complete before this task can be claimed",
)
class TaskAssignInput(BaseModel): class TaskAssignInput(BaseModel):
+15 -3
View File
@@ -634,10 +634,22 @@ def _register_pm_tools(mcp: FastMCP, client: ApiClient, agent_id: str) -> None:
- Only PMs and management can create tasks - Only PMs and management can create tasks
- Cell PMs can only create tasks for their own team - Cell PMs can only create tasks for their own team
ORDERING SUBTASKS:
When creating multiple subtasks, use sequence and dependency_ids:
- sequence: Lower numbers execute first (1, 2, 3...)
- dependency_ids: Task IDs that must complete first
Example for 3 ordered subtasks:
1. "Fix bug" (sequence=1, no deps)
2. "Add feature" (sequence=2, depends on #1)
3. "Write tests" (sequence=3, depends on #1 and #2)
Args: Args:
data: TaskCreateInput with title, description, acceptance_criteria, data: TaskCreateInput with:
team, and optional parent_task_id, assigned_to, priority, status. - title, description, acceptance_criteria, team (required)
Use status="backlog" for subtasks needing session setup. - parent_task_id, assigned_to, priority, status (optional)
- sequence: Order within siblings (0 = default)
- dependency_ids: Task IDs that must complete first
Returns: Returns:
Created task with next step guidance Created task with next step guidance
+10 -4
View File
@@ -145,12 +145,18 @@ async def validate_task_claimable(
"qa": ["pending", "awaiting_qa"], "qa": ["pending", "awaiting_qa"],
# Documenters: pending (direct docs tasks) or awaiting_documentation (workflow) # Documenters: pending (direct docs tasks) or awaiting_documentation (workflow)
"documenter": ["pending", "awaiting_documentation"], "documenter": ["pending", "awaiting_documentation"],
# Developers: pending or needs_revision (after QA/CEO rejection)
"developer": ["pending", "needs_revision"],
# PMs: pending or awaiting_pm_review
"cell_pm": ["pending", "awaiting_pm_review"],
"main_pm": ["pending", "awaiting_pm_review"],
} }
allowed = claimable_statuses.get(agent_role, ["pending"]) # Default for unlisted roles is pending + needs_revision (developer-like behavior)
allowed = claimable_statuses.get(agent_role, ["pending", "needs_revision"])
# Special case: agent can claim pending tasks already assigned to them # Special case: agent can claim tasks already assigned to them
# This handles PM directly assigning tasks to QA/docs agents # This handles PM directly assigning tasks or needs_revision tasks
if task_status == "pending": if task_status in ("pending", "needs_revision"):
assigned_to = task.get("assigned_to") assigned_to = task.get("assigned_to")
if assigned_to: if assigned_to:
agent_uuid = await resolve_agent_uuid_cached(agent_id, client) agent_uuid = await resolve_agent_uuid_cached(agent_id, client)
+68 -3
View File
@@ -114,13 +114,78 @@ async def handle_task_claim(
if claimed_task.get("project_id"): if claimed_task.get("project_id"):
project = await get_project_context(client, claimed_task["project_id"]) project = await get_project_context(client, claimed_task["project_id"])
# Context-aware guidance based on task state
guidance = _build_claim_guidance(claimed_task, task)
next_step = "REVIEW" if claimed_task.get("plan") else "PLAN"
return format_task_response( return format_task_response(
claimed_task, claimed_task,
"PLAN", next_step,
guidance,
project=project,
)
def _build_claim_guidance(claimed_task: dict, original_task: dict) -> str:
"""Build context-aware guidance based on task's previous state."""
original_status = original_task.get("status", "pending")
has_plan = claimed_task.get("plan")
qa_notes = claimed_task.get("qa_notes")
dev_notes = claimed_task.get("dev_notes")
checkpoints = claimed_task.get("checkpoints", [])
progress_updates = claimed_task.get("progress_updates", [])
# NEEDS_REVISION: Task was rejected by QA or CEO - READ FEEDBACK FIRST
if original_status == "needs_revision":
parts = [
"⚠️ REVISION REQUIRED - READ EXISTING CONTEXT FIRST!\n",
"This task was REJECTED and needs fixes. Before doing anything:\n",
]
if qa_notes:
parts.append(f"1. READ QA FEEDBACK: {qa_notes[:200]}...\n")
if has_plan:
plan_steps = has_plan.get("steps", [])
completed = sum(1 for s in plan_steps if s.get("completed"))
parts.append(
f"2. REVIEW EXISTING PLAN: {completed}/{len(plan_steps)} "
"steps completed\n"
)
if dev_notes:
parts.append("3. CHECK DEV NOTES for previous work context\n")
parts.append(
"\nFix the specific issues mentioned, don't restart from scratch.\n"
"Call roboco_task_start() to resume work."
)
return "".join(parts)
# Task with existing plan (resumed/paused/etc)
if has_plan:
plan_steps = has_plan.get("steps", [])
completed = sum(1 for s in plan_steps if s.get("completed"))
parts = [
"📋 EXISTING PLAN FOUND - REVIEW BEFORE CONTINUING!\n",
f"Plan progress: {completed}/{len(plan_steps)} steps completed\n",
]
if checkpoints:
latest_cp = checkpoints[-1]
parts.append(f"Last checkpoint: {latest_cp.get('state_summary', 'N/A')}\n")
if progress_updates:
latest_prog = progress_updates[-1]
parts.append(
f"Last progress: {latest_prog.get('percentage', 0)}% - "
f"{latest_prog.get('message', 'N/A')}\n"
)
parts.append(
"\nREVIEW the plan and continue from where work stopped.\n"
"Call roboco_task_start() to resume work."
)
return "".join(parts)
# Fresh task - no plan yet
return (
"Task claimed. NEXT: Call roboco_task_plan() before you can start.\n" "Task claimed. NEXT: Call roboco_task_plan() before you can start.\n"
"1. Read the description and acceptance criteria\n" "1. Read the description and acceptance criteria\n"
"2. Ask questions if anything is unclear\n" "2. Ask questions if anything is unclear\n"
"3. Call roboco_task_plan(task_id, approach, steps)\n" "3. Call roboco_task_plan(task_id, approach, steps)\n"
"4. Then call roboco_task_start(task_id)", "4. Then call roboco_task_start(task_id)"
project=project,
) )
+3
View File
@@ -258,9 +258,12 @@ def _build_task_payload(input_data: TaskCreateInput) -> dict[str, Any]:
"priority": input_data.priority, "priority": input_data.priority,
"estimated_complexity": input_data.complexity, "estimated_complexity": input_data.complexity,
"status": input_data.status, # Always included, defaults to "backlog" "status": input_data.status, # Always included, defaults to "backlog"
"sequence": input_data.sequence, # Task ordering (lower = first)
} }
if input_data.parent_task_id: if input_data.parent_task_id:
payload["parent_task_id"] = input_data.parent_task_id payload["parent_task_id"] = input_data.parent_task_id
if input_data.dependency_ids:
payload["dependency_ids"] = input_data.dependency_ids
return payload return payload
+9
View File
@@ -34,6 +34,7 @@ class EventType(str, Enum):
TASK_QA_PASSED = "task.qa_passed" TASK_QA_PASSED = "task.qa_passed"
TASK_QA_FAILED = "task.qa_failed" TASK_QA_FAILED = "task.qa_failed"
TASK_AWAITING_DOCS = "task.awaiting_docs" TASK_AWAITING_DOCS = "task.awaiting_docs"
TASK_ESCALATED_TO_MAIN_PM = "task.escalated_to_main_pm" # Cell PM → Main PM
TASK_AWAITING_CEO_APPROVAL = "task.awaiting_ceo_approval" # Escalated to CEO TASK_AWAITING_CEO_APPROVAL = "task.awaiting_ceo_approval" # Escalated to CEO
TASK_CEO_APPROVED = "task.ceo_approved" # CEO approved TASK_CEO_APPROVED = "task.ceo_approved" # CEO approved
TASK_CEO_REJECTED = "task.ceo_rejected" # CEO rejected, needs revision TASK_CEO_REJECTED = "task.ceo_rejected" # CEO rejected, needs revision
@@ -158,8 +159,16 @@ class OrchestratorAccessProtocol(Protocol):
def get_waiting_agents(self) -> dict[str, "WaitingRecord"]: ... def get_waiting_agents(self) -> dict[str, "WaitingRecord"]: ...
def get_running_agents(self) -> set[str]: ...
async def resolve_wait(self, agent_id: str, resolution: dict[str, Any]) -> Any: ... async def resolve_wait(self, agent_id: str, resolution: dict[str, Any]) -> Any: ...
async def spawn_agent(
self,
agent_id: str,
initial_prompt: str | None = None,
) -> Any: ...
# ============================================================================= # =============================================================================
# EVENT CONTEXT (dependency container) # EVENT CONTEXT (dependency container)
+7 -3
View File
@@ -73,12 +73,15 @@ class IndexConversationParams:
@dataclass @dataclass
class IndexJournalEntryParams: class IndexJournalEntryParams:
"""Parameters for indexing a journal entry.""" """Parameters for indexing a journal entry.
Note: entry_id and agent_id can be None for system events (e.g., lifecycle events).
"""
entry_id: UUID
agent_id: UUID
content: str content: str
entry_type: str entry_type: str
entry_id: UUID | None = None
agent_id: UUID | None = None
task_id: UUID | None = None task_id: UUID | None = None
tags: list[str] | None = None tags: list[str] | None = None
@@ -171,6 +174,7 @@ class IndexStandardParams:
title: str title: str
content: str content: str
language: str | None = None language: str | None = None
scope: str | None = None
severity: str = "recommended" severity: str = "recommended"
tags: list[str] | None = None tags: list[str] | None = None
source_file: str | None = None source_file: str | None = None
+13
View File
@@ -279,6 +279,15 @@ class TaskCreate(RobocoBase):
estimated_complexity: Complexity = Complexity.MEDIUM estimated_complexity: Complexity = Complexity.MEDIUM
status: TaskStatus | None = None # PM can set 'backlog' for subtasks needing setup status: TaskStatus | None = None # PM can set 'backlog' for subtasks needing setup
# Ordering and dependencies
sequence: int = Field(
default=0, description="Order within siblings (lower = first)"
)
dependency_ids: list[UUID] = Field(
default_factory=list,
description="Task IDs that must complete before this task can be claimed",
)
# Git configuration # Git configuration
task_type: TaskType = TaskType.CODE task_type: TaskType = TaskType.CODE
requires_git: bool = True requires_git: bool = True
@@ -333,6 +342,10 @@ class TaskCreateRequest:
estimated_complexity: Complexity = field(default=Complexity.MEDIUM) estimated_complexity: Complexity = field(default=Complexity.MEDIUM)
status: TaskStatus | None = None # PM can set BACKLOG for subtasks status: TaskStatus | None = None # PM can set BACKLOG for subtasks
# Ordering and dependencies
sequence: int = 0 # Order within siblings (lower = first)
dependency_ids: list[UUID] = field(default_factory=list)
# Git configuration # Git configuration
task_type: TaskType = field(default=TaskType.CODE) task_type: TaskType = field(default=TaskType.CODE)
requires_git: bool = True requires_git: bool = True
+4
View File
@@ -178,6 +178,10 @@ class AgentOrchestrator:
logger.info("Orchestrator stopped") logger.info("Orchestrator stopped")
def get_running_agents(self) -> set[str]:
"""Get set of currently running agent IDs."""
return set(self._instances.keys())
async def _ensure_agent_image(self, agent_id: str | None = None) -> None: async def _ensure_agent_image(self, agent_id: str | None = None) -> None:
"""Ensure the agent Docker images are built. """Ensure the agent Docker images are built.
+3 -3
View File
@@ -7,7 +7,7 @@ Provides business logic for A2A protocol operations including:
- Message handling and routing - Message handling and routing
""" """
from typing import Any from typing import Any, cast
from uuid import UUID from uuid import UUID
import structlog import structlog
@@ -517,7 +517,7 @@ class A2AService:
# Check for explicit target # Check for explicit target
target = metadata.get("target_agent") target = metadata.get("target_agent")
if target and target in ALL_AGENTS: if target and target in ALL_AGENTS:
return target return cast("str", target)
# Check for skill-based routing # Check for skill-based routing
skill = metadata.get("skill") skill = metadata.get("skill")
@@ -549,7 +549,7 @@ class A2AService:
return return
# Assign task to target agent # Assign task to target agent
task.assigned_to = UUID(target_uuid) task.assigned_to = cast("Any", UUID(target_uuid))
await self.session.flush() await self.session.flush()
# Publish A2A request event for routing # Publish A2A request event for routing
+6 -5
View File
@@ -46,6 +46,7 @@ from roboco.models.session import (
SessionTaskRelationshipType, SessionTaskRelationshipType,
) )
from roboco.services.base import BaseService, ConflictError, NotFoundError from roboco.services.base import BaseService, ConflictError, NotFoundError
from roboco.utils.converters import require_uuid, to_python_uuid
# ============================================================================= # =============================================================================
# MESSAGING SERVICE # MESSAGING SERVICE
@@ -811,7 +812,7 @@ class MessagingService(BaseService):
await self.session.flush() await self.session.flush()
# Deliver via Redis Streams -> WebSocket # Deliver via Redis Streams -> WebSocket
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
self.log.debug( self.log.debug(
"Mention notification sent", "Mention notification sent",
@@ -829,10 +830,10 @@ class MessagingService(BaseService):
await optimal.index_conversation( await optimal.index_conversation(
IndexConversationParams( IndexConversationParams(
content=message.content, content=message.content,
channel_id=message.channel_id, channel_id=require_uuid(message.channel_id),
session_id=message.session_id, session_id=require_uuid(message.session_id),
agent_id=message.agent_id, agent_id=require_uuid(message.agent_id),
task_id=message.task_id, task_id=to_python_uuid(message.task_id),
message_type=message.type.value if message.type else None, message_type=message.type.value if message.type else None,
) )
) )
+2 -1
View File
@@ -12,6 +12,7 @@ from roboco.db.base import get_db_context
from roboco.db.tables import NotificationTable from roboco.db.tables import NotificationTable
from roboco.models import NotificationPriority, NotificationType from roboco.models import NotificationPriority, NotificationType
from roboco.models.notification import CreateNotificationParams from roboco.models.notification import CreateNotificationParams
from roboco.utils.converters import require_uuid
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -191,7 +192,7 @@ class NotificationService:
) )
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
await db.commit() await db.commit()
+3 -3
View File
@@ -396,8 +396,8 @@ class OptimalService:
title=f"Message in {params.channel_id or 'channel'}", title=f"Message in {params.channel_id or 'channel'}",
preview=params.content[:500] if params.content else None, preview=params.content[:500] if params.content else None,
metadata={ metadata={
"channel_id": params.channel_id, "channel_id": str(params.channel_id) if params.channel_id else None,
"session_id": params.session_id, "session_id": str(params.session_id) if params.session_id else None,
"agent_id": str(params.agent_id) if params.agent_id else None, "agent_id": str(params.agent_id) if params.agent_id else None,
}, },
) )
@@ -425,7 +425,7 @@ class OptimalService:
title=f"Journal: {params.entry_type or 'entry'}", title=f"Journal: {params.entry_type or 'entry'}",
preview=params.content[:500] if params.content else None, preview=params.content[:500] if params.content else None,
metadata={ metadata={
"entry_id": params.entry_id, "entry_id": str(params.entry_id) if params.entry_id else None,
"agent_id": str(params.agent_id) if params.agent_id else None, "agent_id": str(params.agent_id) if params.agent_id else None,
"entry_type": params.entry_type, "entry_type": params.entry_type,
"tags": params.tags, "tags": params.tags,
+152 -36
View File
@@ -26,10 +26,11 @@ from roboco.enforcement import (
validate_task_transition, validate_task_transition,
) )
from roboco.events import Event, EventType, get_event_bus from roboco.events import Event, EventType, get_event_bus
from roboco.models.base import TaskStatus, Team from roboco.models.base import AgentRole, TaskStatus, Team
from roboco.models.task import TaskCreateRequest from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionStatus from roboco.models.work_session import WorkSessionStatus
from roboco.services.base import BaseService from roboco.services.base import BaseService
from roboco.utils.converters import require_uuid, to_python_uuid
# UUID format constants for validation # UUID format constants for validation
_UUID_LENGTH = 36 # Standard UUID string length _UUID_LENGTH = 36 # Standard UUID string length
@@ -207,6 +208,8 @@ class TaskService(BaseService):
target_date=req.target_date, target_date=req.target_date,
estimated_complexity=req.estimated_complexity, estimated_complexity=req.estimated_complexity,
status=req.status if req.status else TaskStatus.PENDING, 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) self.session.add(task)
await self.session.flush() await self.session.flush()
@@ -822,12 +825,12 @@ class TaskService(BaseService):
for content, ltype in learnings: for content, ltype in learnings:
await learning_svc.record_learning( await learning_svc.record_learning(
RecordLearningParams( RecordLearningParams(
agent_id=assigned_to or agent_id or UUID(int=0), agent_id=to_python_uuid(assigned_to) or agent_id or UUID(int=0),
agent_role="developer", agent_role="developer",
content=content, content=content,
learning_type=ltype, learning_type=ltype,
scope=scope, scope=scope,
task_id=task_id, task_id=to_python_uuid(task_id),
tags=["auto-extracted", task_team or "general"], tags=["auto-extracted", task_team or "general"],
) )
) )
@@ -1159,7 +1162,8 @@ class TaskService(BaseService):
task_team: Team for categorization task_team: Team for categorization
details: Additional event details details: Additional event details
""" """
from roboco.services.optimal import IndexType, get_optimal_service from roboco.models.optimal import IndexJournalEntryParams
from roboco.services.optimal import get_optimal_service
try: try:
optimal = await get_optimal_service() optimal = await get_optimal_service()
@@ -1168,18 +1172,18 @@ class TaskService(BaseService):
# Build content for indexing # Build content for indexing
content = f"[{event_type.upper()}] {task_title}" content = f"[{event_type.upper()}] {task_title}"
if details: if details:
content += f"\n{details}" content += f"\nDetails: {details}"
# Index to journals for lifecycle tracking # Index to journals for lifecycle tracking
await optimal.ingest( await optimal.index_journal_entry(
index_type=IndexType.JOURNALS, IndexJournalEntryParams(
content=content, content=content,
doc_id=f"lifecycle-{task_id}-{event_type}", entry_id=None, # Will be auto-generated
task_id=task_id, agent_id=None, # System event, no specific agent
project=task_team.value if task_team else "default", entry_type=f"lifecycle_{event_type}",
entry_type="lifecycle", task_id=task_id,
event_type=event_type, tags=[event_type, task_team.value if task_team else "default"],
**details, )
) )
self.log.debug( self.log.debug(
"Indexed lifecycle event", "Indexed lifecycle event",
@@ -1361,7 +1365,9 @@ class TaskService(BaseService):
"what_needed": what_needed, "what_needed": what_needed,
} }
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._index_blocker_background(task.id, task.team, blocker_info) self._index_blocker_background(
require_uuid(task.id), task.team, blocker_info
)
) )
self._background_tasks.add(bg_task) self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard) bg_task.add_done_callback(self._background_tasks.discard)
@@ -1539,11 +1545,11 @@ class TaskService(BaseService):
# Index positive QA review (fire-and-forget) # Index positive QA review (fire-and-forget)
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._index_qa_review_background( self._index_qa_review_background(
task.id, require_uuid(task.id),
task.quick_context, task.quick_context,
True, True,
notes or "Passed QA review", notes or "Passed QA review",
qa_agent_id, to_python_uuid(qa_agent_id),
) )
) )
self._background_tasks.add(bg_task) self._background_tasks.add(bg_task)
@@ -1605,11 +1611,11 @@ class TaskService(BaseService):
# Index negative QA review (fire-and-forget) # Index negative QA review (fire-and-forget)
review_task = asyncio.create_task( review_task = asyncio.create_task(
self._index_qa_review_background( self._index_qa_review_background(
task.id, require_uuid(task.id),
task.quick_context, task.quick_context,
False, False,
notes, notes,
qa_agent_id, to_python_uuid(qa_agent_id),
) )
) )
self._background_tasks.add(review_task) self._background_tasks.add(review_task)
@@ -1617,7 +1623,9 @@ class TaskService(BaseService):
# Index issues as error patterns (fire-and-forget) # Index issues as error patterns (fire-and-forget)
error_task = asyncio.create_task( error_task = asyncio.create_task(
self._index_qa_errors_background(task.id, task.title, task.team, notes) self._index_qa_errors_background(
require_uuid(task.id), task.title, task.team, notes
)
) )
self._background_tasks.add(error_task) self._background_tasks.add(error_task)
error_task.add_done_callback(self._background_tasks.discard) error_task.add_done_callback(self._background_tasks.discard)
@@ -1738,7 +1746,7 @@ class TaskService(BaseService):
# Index documentation artifacts (fire-and-forget) # Index documentation artifacts (fire-and-forget)
if task.documents: if task.documents:
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._index_docs_background(task.id, task.documents) self._index_docs_background(require_uuid(task.id), task.documents)
) )
self._background_tasks.add(bg_task) self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard) bg_task.add_done_callback(self._background_tasks.discard)
@@ -1899,7 +1907,7 @@ class TaskService(BaseService):
) )
return task return task
async def complete( async def complete( # noqa: PLR0911, PLR0912, PLR0915
self, self,
task_id: UUID, task_id: UUID,
agent_id: UUID | None = None, agent_id: UUID | None = None,
@@ -1909,9 +1917,10 @@ class TaskService(BaseService):
""" """
Mark task as completed (PM only). Mark task as completed (PM only).
Two completion paths: Approval hierarchy:
1. Developer work: task must be in AWAITING_PM_REVIEW (went through QA/Docs) 1. Cell PM reviews reassigns to Main PM (same awaiting_pm_review state)
2. PM's own task: task can be IN_PROGRESS if assigned to the completing PM 2. Main PM reviews leaf task completes
3. Main PM reviews parent task (all descendants terminal) escalates to CEO
PM Override for cancelled subtasks: PM Override for cancelled subtasks:
Use force_with_cancelled=True with justification to complete despite Use force_with_cancelled=True with justification to complete despite
@@ -1931,6 +1940,20 @@ class TaskService(BaseService):
if not task: if not task:
return None return None
# Get the completing agent's role
completing_agent_role = None
if agent_id:
agent_result = await self.session.execute(
select(AgentTable).where(AgentTable.id == agent_id)
)
completing_agent = agent_result.scalar_one_or_none()
if completing_agent and completing_agent.role:
completing_agent_role = (
completing_agent.role.value
if hasattr(completing_agent.role, "value")
else str(completing_agent.role)
)
# Check if PM is completing their own task (assigned to them) # Check if PM is completing their own task (assigned to them)
is_own_task = agent_id and task.assigned_to == agent_id is_own_task = agent_id and task.assigned_to == agent_id
@@ -1968,6 +1991,50 @@ class TaskService(BaseService):
) )
return None return None
# APPROVAL HIERARCHY: Cell PM → Main PM → CEO
if task.status == TaskStatus.AWAITING_PM_REVIEW:
# Cell PM reviewed - escalate to Main PM
if completing_agent_role == "cell_pm":
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 main_pm:
task.assigned_to = cast("Any", main_pm.id)
await self.session.flush()
# Emit event for Main PM notification
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 # Return without completing - Main PM needs to review
else:
self.log.warning(
"No Main PM found - proceeding with completion",
task_id=str(task_id),
)
# Main PM reviewed - check if parent task needs CEO approval
if completing_agent_role == "main_pm" and all_descendants:
# Parent task with descendants - needs CEO approval
self.log.info(
"Main PM approved parent task - escalating to CEO",
task_id=str(task_id),
descendant_count=len(all_descendants),
)
return await self.escalate_to_ceo(task_id, "main_pm")
# Check for cancelled descendants (only matters if force override requested) # Check for cancelled descendants (only matters if force override requested)
cancelled_descendants = [ cancelled_descendants = [
st for st in all_descendants if st.status == TaskStatus.CANCELLED st for st in all_descendants if st.status == TaskStatus.CANCELLED
@@ -2015,7 +2082,7 @@ class TaskService(BaseService):
if task.commits: if task.commits:
code_task = asyncio.create_task( code_task = asyncio.create_task(
self._index_code_changes_background( self._index_code_changes_background(
task.id, require_uuid(task.id),
task.commits, task.commits,
task.team.value if task.team else "default", task.team.value if task.team else "default",
) )
@@ -2027,7 +2094,11 @@ class TaskService(BaseService):
if task.dev_notes: if task.dev_notes:
decision_task = asyncio.create_task( decision_task = asyncio.create_task(
self._index_decisions_background( self._index_decisions_background(
task.id, task.title, task.team, task.dev_notes, task.assigned_to require_uuid(task.id),
task.title,
task.team,
task.dev_notes,
to_python_uuid(task.assigned_to),
) )
) )
self._background_tasks.add(decision_task) self._background_tasks.add(decision_task)
@@ -2446,13 +2517,17 @@ class TaskService(BaseService):
status: TaskStatus | None = None, status: TaskStatus | None = None,
limit: int = 100, limit: int = 100,
) -> list[TaskTable]: ) -> list[TaskTable]:
"""List tasks for a specific team.""" """List tasks for a team, ordered by priority, sequence, created_at."""
query = select(TaskTable).where(TaskTable.team == team) query = select(TaskTable).where(TaskTable.team == team)
if status: if status:
query = query.where(TaskTable.status == status) query = query.where(TaskTable.status == status)
query = query.order_by(TaskTable.priority, TaskTable.created_at.desc()) query = query.order_by(
TaskTable.priority,
TaskTable.sequence,
TaskTable.created_at,
)
query = query.limit(limit) query = query.limit(limit)
result = await self.session.execute(query) result = await self.session.execute(query)
@@ -2513,20 +2588,61 @@ class TaskService(BaseService):
status: TaskStatus, status: TaskStatus,
team: Team | None = None, team: Team | None = None,
) -> list[TaskTable]: ) -> list[TaskTable]:
"""List tasks with a specific status.""" """List tasks by status, ordered by priority, sequence, created_at."""
query = select(TaskTable).where(TaskTable.status == status) query = select(TaskTable).where(TaskTable.status == status)
if team: if team:
query = query.where(TaskTable.team == team) query = query.where(TaskTable.team == team)
query = query.order_by(TaskTable.priority, TaskTable.created_at.desc()) # 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) result = await self.session.execute(query)
return list(result.scalars().all()) return list(result.scalars().all())
async def list_pending(self, team: Team | None = None) -> list[TaskTable]: async def list_pending(
"""List pending tasks (available to claim).""" self,
return await self.list_by_status(TaskStatus.PENDING, team) 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]: async def list_blocked(self, team: Team | None = None) -> list[TaskTable]:
"""List blocked tasks.""" """List blocked tasks."""
@@ -2660,7 +2776,7 @@ async def resolve_pm_for_substitute(
select(AgentTable).where(AgentTable.slug == target_pm_slug) select(AgentTable).where(AgentTable.slug == target_pm_slug)
) )
pm_agent = pm_result.scalar_one_or_none() pm_agent = pm_result.scalar_one_or_none()
return target_pm_slug, pm_agent.id if pm_agent else None return target_pm_slug, to_python_uuid(pm_agent.id) if pm_agent else None
async def notify_pm_for_substitute( async def notify_pm_for_substitute(
@@ -2703,7 +2819,7 @@ async def notify_pm_for_substitute(
await db.flush() await db.flush()
delivery_service = get_notification_delivery_service(db) delivery_service = get_notification_delivery_service(db)
await delivery_service.deliver(notification.id) await delivery_service.deliver(require_uuid(notification.id))
# ============================================================================= # =============================================================================