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