V0 finally out

This commit is contained in:
Renn F
2025-12-14 22:05:34 +01:00
parent 348795c50b
commit bc9cd7575d
17 changed files with 1248 additions and 240 deletions
+24
View File
@@ -0,0 +1,24 @@
.git
.gitignore
.github
.venv
.env
.env.*
__pycache__
*.pyc
*.pyo
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
htmlcov
dist
build
*.egg-info
.tasks
docs
tests
*.md
!README.md
Makefile
.pre-commit-config.yaml
+51 -5
View File
@@ -1,18 +1,52 @@
# ============================================================================= # =============================================================================
# RoboCo Environment Configuration # RoboCo Environment Configuration
# ============================================================================= # =============================================================================
# Copy this file to .env and fill in your values # Copy this file to .env
#
# NOTE: No API keys needed for agents - they use your Claude Code authentication.
# Run `claude` on the host to authenticate before starting RoboCo.
# =============================================================================
# Docker Deployment (NAS/Server)
# =============================================================================
# These are REQUIRED when running via docker compose on a NAS/server.
# They tell the orchestrator container where to find files on the HOST.
# Path to the project on the host (absolute path)
# ROBOCO_HOST_PROJECT_DIR=/volume1/roboco
# Path to Claude Code auth directory on the host
# ROBOCO_HOST_CLAUDE_DIR=/root/.claude
# Claude auth directory to mount into orchestrator
# CLAUDE_AUTH_DIR=~/.claude
# =============================================================================
# Data Persistence
# =============================================================================
# Set to a path on your NAS RAID array for durability
# Path to data directory on the host (MUST be absolute for Docker-in-Docker)
# ROBOCO_DATA_DIR=/volume1/roboco/data
# =============================================================================
# Application # Application
# =============================================================================
ROBOCO_ENVIRONMENT=development ROBOCO_ENVIRONMENT=development
ROBOCO_DEBUG=true ROBOCO_DEBUG=true
ROBOCO_LOG_LEVEL=INFO ROBOCO_LOG_LEVEL=INFO
# =============================================================================
# API Server # API Server
# =============================================================================
ROBOCO_HOST=0.0.0.0 ROBOCO_HOST=0.0.0.0
ROBOCO_PORT=8000 ROBOCO_PORT=8000
# =============================================================================
# Database (PostgreSQL) # Database (PostgreSQL)
# =============================================================================
# For docker compose deployment, use container name:
# ROBOCO_DATABASE_HOST=roboco-postgres
# For local development:
ROBOCO_DATABASE_HOST=localhost ROBOCO_DATABASE_HOST=localhost
ROBOCO_DATABASE_PORT=5432 ROBOCO_DATABASE_PORT=5432
ROBOCO_DATABASE_USER=roboco ROBOCO_DATABASE_USER=roboco
@@ -20,23 +54,35 @@ ROBOCO_DATABASE_PASSWORD=roboco
ROBOCO_DATABASE_NAME=roboco ROBOCO_DATABASE_NAME=roboco
ROBOCO_DATABASE_ECHO=false ROBOCO_DATABASE_ECHO=false
# =============================================================================
# Redis # Redis
# =============================================================================
# For docker compose deployment, use container name:
# ROBOCO_REDIS_HOST=roboco-redis
# For local development:
ROBOCO_REDIS_HOST=localhost ROBOCO_REDIS_HOST=localhost
ROBOCO_REDIS_PORT=6379 ROBOCO_REDIS_PORT=6379
ROBOCO_REDIS_DB=0 ROBOCO_REDIS_DB=0
# ROBOCO_REDIS_PASSWORD= # ROBOCO_REDIS_PASSWORD=
# Qdrant (Vector DB) # =============================================================================
# Qdrant (Vector DB) - Optional
# =============================================================================
ROBOCO_QDRANT_HOST=localhost ROBOCO_QDRANT_HOST=localhost
ROBOCO_QDRANT_PORT=6333 ROBOCO_QDRANT_PORT=6333
# ROBOCO_QDRANT_API_KEY= # ROBOCO_QDRANT_API_KEY=
# AI/LLM Providers # =============================================================================
ROBOCO_ANTHROPIC_API_KEY=your-anthropic-api-key # OpenAI (optional, for embeddings)
ROBOCO_OPENAI_API_KEY=your-openai-api-key # =============================================================================
# ROBOCO_OPENAI_API_KEY=your-openai-api-key
# =============================================================================
# Security # Security
# =============================================================================
ROBOCO_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars ROBOCO_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars
# =============================================================================
# CORS (comma-separated origins) # CORS (comma-separated origins)
# =============================================================================
ROBOCO_CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"] ROBOCO_CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
+161 -34
View File
@@ -28,6 +28,119 @@ upgrade:
@uv sync --all-extras @uv sync --all-extras
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf @find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
# =============================================================================
# INFRASTRUCTURE
# =============================================================================
# Default agents to spawn
AGENTS ?= main-pm be-dev-1 be-qa
# Start infrastructure (PostgreSQL + Redis)
.PHONY: infra
infra:
@echo "Starting infrastructure..."
@docker compose up -d postgres redis
@echo "Waiting for services to be healthy..."
@sleep 3
@docker compose ps
# Stop infrastructure
.PHONY: infra-down
infra-down:
@echo "Stopping infrastructure..."
@docker compose down
# Run database migrations
.PHONY: migrate
migrate:
@echo "Running database migrations..."
@uv run alembic upgrade head
# Create new migration
.PHONY: migration
migration:
@read -p "Migration message: " msg; \
uv run alembic revision --autogenerate -m "$$msg"
# =============================================================================
# RUNNING THE APPLICATION
# =============================================================================
# Start API server only (development mode with reload)
.PHONY: api
api:
@echo "Starting RoboCo API (development mode)..."
@uv run uvicorn roboco.api.app:app --host 0.0.0.0 --port 8000 --reload
# Start API server (production mode, no reload)
.PHONY: run
run:
@echo "Starting RoboCo API (production mode)..."
@uv run uvicorn roboco.api.app:app --host 0.0.0.0 --port 8000
# Start orchestrator only (spawns agents)
.PHONY: orchestrator
orchestrator:
@echo "Starting orchestrator with agents: $(AGENTS)..."
@uv run python -m roboco.cli --spawn $(AGENTS)
# Start API + Orchestrator (full development mode)
.PHONY: dev
dev:
@echo "Starting RoboCo in development mode..."
@echo "Agents to spawn: $(AGENTS)"
@echo ""
@echo "Starting API in background..."
@uv run uvicorn roboco.api.app:app --host 0.0.0.0 --port 8000 &
@sleep 2
@echo "Starting orchestrator..."
@uv run python -m roboco.cli --spawn $(AGENTS)
# Initialize database only (seed data)
.PHONY: db-init
db-init:
@echo "Initializing database..."
@uv run python -m roboco.cli --db-only
# =============================================================================
# MONITORING & STATUS
# =============================================================================
# Show system status
.PHONY: status
status:
@echo "=== Infrastructure ==="
@docker compose ps
@echo ""
@echo "=== API Health ==="
@curl -s http://localhost:8000/health 2>/dev/null | jq . || echo "API not running"
@echo ""
@echo "=== Orchestrator Status ==="
@curl -s http://localhost:8000/api/v1/orchestrator/status 2>/dev/null | jq . || echo "Orchestrator not available"
# Tail all logs
.PHONY: logs
logs:
@docker compose logs -f
# =============================================================================
# TMUX SESSION
# =============================================================================
# Create tmux session with all components
.PHONY: tmux
tmux:
@echo "Creating tmux session 'roboco'..."
@tmux kill-session -t roboco 2>/dev/null || true
@tmux new-session -d -s roboco -n infra
@tmux send-keys -t roboco:infra "cd $(PWD) && docker compose logs -f" Enter
@tmux new-window -t roboco -n api
@tmux send-keys -t roboco:api "cd $(PWD) && make api" Enter
@tmux new-window -t roboco -n orch
@tmux send-keys -t roboco:orch "cd $(PWD) && sleep 3 && make orchestrator AGENTS='$(AGENTS)'" Enter
@tmux select-window -t roboco:api
@echo "tmux session 'roboco' created. Attach with: tmux attach -t roboco"
# Stop # Stop
.PHONY: stop .PHONY: stop
stop: stop:
@@ -254,41 +367,55 @@ clean:
# Help # Help
.PHONY: help .PHONY: help
help: help:
@echo "Available commands:" @echo "RoboCo - AI Agents Company"
@echo " make install - Install dependencies" @echo ""
@echo " make install-dev - Install dev dependencies" @echo "Infrastructure:"
@echo " make lock - Update dependencies" @echo " make infra - Start PostgreSQL + Redis"
@echo " make start-example - Start example application with docker compose" @echo " make infra-down - Stop infrastructure"
@echo " make run-example - Build and run example container directly" @echo " make migrate - Run database migrations"
@echo " make stop - Stop all containers and clean up resources" @echo " make migration - Create new migration"
@echo " make restart - Restart example application" @echo " make db-init - Initialize/seed database"
@echo " make lint - Run linting checks" @echo ""
@echo " make fix - Auto-fix linting issues" @echo "Running:"
@echo " make vulture - Find dead code with Vulture" @echo " make dev - Start API + Orchestrator (development)"
@echo " make bandit - Run Bandit security scan" @echo " make dev AGENTS='a b c' - Start with specific agents"
@echo " make safety - Check dependencies with Safety" @echo " make api - Start API only (with reload)"
@echo " make pip-audit - Audit dependencies with pip-audit" @echo " make run - Start API only (production)"
@echo " make radon - Analyze code complexity with Radon" @echo " make orchestrator - Start orchestrator only"
@echo " make xenon - Check complexity thresholds with Xenon" @echo " make tmux - Create tmux session with all components"
@echo " make deptry - Analyze dependencies with Deptry" @echo ""
@echo " make semgrep - Run Semgrep static analysis" @echo "Monitoring:"
@echo " make security - Run all security checks" @echo " make status - Show system status"
@echo " make quality - Run all code quality checks" @echo " make logs - Tail infrastructure logs"
@echo " make analysis - Run all analysis tools" @echo ""
@echo " make check-all - Run all checks (lint, security, quality, analysis)" @echo "Dependencies:"
@echo " make test - Run tests with Python $(DEFAULT_PYTHON)" @echo " make install - Install dependencies"
@echo " make test-all - Run tests with all Python versions ($(PYTHON_VERSIONS))" @echo " make install-dev - Install dev dependencies"
@echo " make test-<version> - Run tests with specific Python version (e.g., make test-3.10)" @echo " make lock - Update lock file"
@echo " make local-test - Run tests locally" @echo " make upgrade - Upgrade all dependencies"
@echo " make stress-test - Run stress test" @echo ""
@echo " make high-load-stress-test - Run high-load stress test" @echo "Code Quality:"
@echo " make serve-docs - Serve documentation" @echo " make lint - Run linting (ruff, mypy, vulture)"
@echo " make lint-docs - Run markdownlint on documentation" @echo " make fix - Auto-fix linting issues"
@echo " make fix-docs - Auto-fix markdownlint issues" @echo " make quality - Run all quality checks"
@echo " make prune - Prune docker resources" @echo " make security - Run security checks (bandit, safety, pip-audit)"
@echo " make check-all - Run ALL checks"
@echo ""
@echo "Testing:"
@echo " make test - Run tests (Python $(DEFAULT_PYTHON))"
@echo " make test-all - Run tests (all Python versions)"
@echo " make stress-test - Run stress test"
@echo ""
@echo "Documentation:"
@echo " make serve-docs - Serve documentation"
@echo " make lint-docs - Lint markdown files"
@echo ""
@echo "Cleanup:"
@echo " make stop - Stop all containers"
@echo " make clean - Clean cache files" @echo " make clean - Clean cache files"
@echo " make help - Show this help message" @echo " make prune - Prune docker resources"
@echo " make show-python-versions - Show supported Python versions" @echo ""
@echo "See docs/deployment.md and docs/usage.md for detailed guides."
# Python versions list # Python versions list
.PHONY: show-python-versions .PHONY: show-python-versions
+68 -9
View File
@@ -11,9 +11,9 @@ services:
POSTGRES_PASSWORD: roboco POSTGRES_PASSWORD: roboco
POSTGRES_DB: roboco POSTGRES_DB: roboco
ports: ports:
- "5432:5432" - "15432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"] test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
interval: 10s interval: 10s
@@ -21,23 +21,82 @@ services:
retries: 5 retries: 5
# ========================================================================== # ==========================================================================
# Redis - Cache, Sessions, Message Queue # Redis - Cache, Sessions, Event Bus
# ========================================================================== # ==========================================================================
redis: redis:
image: redis:7-alpine image: redis:8-alpine
container_name: roboco-redis container_name: roboco-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes
ports: ports:
- "6379:6379" - "16379:6379"
volumes: volumes:
- redis_data:/data - ${ROBOCO_DATA_DIR:-./data}/redis:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
volumes: # ==========================================================================
postgres_data: # Agent Image Builder (pre-builds the agent image at compose time)
redis_data: # ==========================================================================
agent-image:
build:
context: .
dockerfile: docker/agent.Dockerfile
image: roboco-agent
container_name: roboco-agent-builder
entrypoint: ["/bin/sh", "-c", "echo 'Agent image built successfully'"]
restart: "no"
# ==========================================================================
# Orchestrator - API Server + Agent Spawner
# ==========================================================================
orchestrator:
build:
context: .
dockerfile: docker/orchestrator.Dockerfile
image: roboco-orchestrator
container_name: roboco-orchestrator
restart: unless-stopped
ports:
- "8000:8000"
environment:
# Database (use container name, not localhost)
ROBOCO_DATABASE_HOST: roboco-postgres
ROBOCO_DATABASE_PORT: 5432
ROBOCO_DATABASE_USER: roboco
ROBOCO_DATABASE_PASSWORD: roboco
ROBOCO_DATABASE_NAME: roboco
# Redis (use container name)
ROBOCO_REDIS_HOST: roboco-redis
ROBOCO_REDIS_PORT: 6379
# API
ROBOCO_HOST: 0.0.0.0
ROBOCO_PORT: 8000
# Host paths for spawning agent containers (required for Docker-in-Docker)
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
volumes:
# Docker socket - allows spawning agent containers
- /var/run/docker.sock:/var/run/docker.sock
# Claude Code auth - mount your ~/.claude directory
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
# Shared config directory for MCP configs (writable)
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
agent-image:
condition: service_completed_successfully
# Default agents to spawn (override in .env or command line)
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
networks:
default:
name: roboco_default
+45
View File
@@ -0,0 +1,45 @@
FROM debian:bookworm-slim
# Install dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
python3 \
python3-pip \
python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 22
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Install uv for Python package management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Create agent user BEFORE copying files
RUN useradd -m -s /bin/bash agent
# Create app directory and set ownership
WORKDIR /app
RUN chown agent:agent /app
# Copy MCP server code (needed for agent tools) AS agent
COPY --chown=agent:agent roboco /app/roboco
COPY --chown=agent:agent pyproject.toml uv.lock README.md /app/
# Switch to agent user for installing dependencies
USER agent
# Install Python dependencies for MCP servers (as agent)
RUN uv python install 3.13 && uv sync --frozen --python 3.13
# Claude Code will use mounted ~/.claude for auth
# Blueprints mounted at /app/agents/blueprints
# MCP config generated at runtime
ENTRYPOINT ["claude"]
+48
View File
@@ -0,0 +1,48 @@
FROM debian:bookworm-slim
# Install dependencies + Docker CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
python3 \
python3-pip \
python3-venv \
gnupg \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
# Install Docker CLI (for spawning agent containers)
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 22 (for building agent image)
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install uv for Python package management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Copy project files
COPY roboco /app/roboco
COPY agents /app/agents
COPY docker /app/docker
COPY pyproject.toml uv.lock alembic.ini README.md /app/
COPY alembic /app/alembic
# Install Python dependencies
RUN uv python install 3.13 && uv sync --frozen --python 3.13
# Expose API port
EXPOSE 8000
# Default: start with main-pm, be-dev-1, be-qa
# Override with: docker run ... roboco-orchestrator --spawn main-pm fe-dev-1
ENTRYPOINT ["uv", "run", "python", "-m", "roboco.cli"]
CMD ["--spawn", "main-pm", "be-dev-1", "be-qa"]
+215
View File
@@ -0,0 +1,215 @@
# RoboCo Deployment Guide
Deploy RoboCo on your server/NAS.
## Prerequisites
| Software | Purpose |
|----------|---------|
| Docker | All services (PostgreSQL, Redis, Orchestrator, Agents) |
| Claude Code CLI | Authenticate once on host (creates ~/.claude) |
```bash
# Install and authenticate Claude Code (one time)
npm install -g @anthropic-ai/claude-code
claude # Login via browser
```
## Quick Start (NAS/Server)
Everything runs in Docker - no need to install Python/uv on the host.
```bash
# 1. Clone the project
git clone <repo-url> roboco
cd roboco
# 2. Configure environment
cp .env.example .env
# 3. Set host paths for your NAS (IMPORTANT!)
# Edit .env and set:
# ROBOCO_HOST_PROJECT_DIR=/volume1/roboco
# ROBOCO_HOST_CLAUDE_DIR=/root/.claude (or your user's home)
# 4. Start everything (PostgreSQL + Redis + Orchestrator)
docker compose up -d
# 5. View logs
docker compose logs -f orchestrator
```
## Architecture
```
Your NAS/Server
├── docker compose up -d
│ │
│ ├── roboco-postgres (pgvector)
│ ├── roboco-redis
│ └── roboco-orchestrator
│ │
│ ├── Runs FastAPI on port 8000
│ ├── Builds roboco-agent image (once)
│ └── Spawns agent containers:
│ ├── roboco-agent-main-pm
│ ├── roboco-agent-be-dev-1
│ ├── roboco-agent-be-qa
│ └── ... (each mounts ~/.claude for auth)
├── ~/.claude/ ← Your Claude Code auth (from host)
└── ./data/ ← Persistent data
├── postgres/
├── redis/
└── mcp-configs/
```
## Configuration
### Environment Variables (.env)
```bash
# Host paths - REQUIRED for NAS deployment
# These tell the orchestrator where to find files on the HOST
ROBOCO_HOST_PROJECT_DIR=/volume1/roboco
ROBOCO_HOST_CLAUDE_DIR=/root/.claude
ROBOCO_DATA_DIR=./data
# Claude Code auth directory (mounted into containers)
CLAUDE_AUTH_DIR=/root/.claude
# Database (defaults work for docker compose)
ROBOCO_DATABASE_HOST=roboco-postgres
ROBOCO_DATABASE_PORT=5432
ROBOCO_DATABASE_USER=roboco
ROBOCO_DATABASE_PASSWORD=roboco
ROBOCO_DATABASE_NAME=roboco
# Redis (defaults work for docker compose)
ROBOCO_REDIS_HOST=roboco-redis
ROBOCO_REDIS_PORT=6379
```
### Customizing Agent Spawn
By default, the orchestrator spawns `main-pm`, `be-dev-1`, and `be-qa`. To change this:
```bash
# Option 1: Override in docker-compose.yml
docker compose up -d --scale orchestrator=0
docker compose run orchestrator --spawn main-pm fe-dev-1 fe-qa
# Option 2: Edit docker-compose.yml command section
# Uncomment and modify the command line
```
## Verification
```bash
# API health
curl http://localhost:8000/health
# Orchestrator status (shows running containers)
curl http://localhost:8000/api/v1/orchestrator/status | jq
# List all RoboCo containers
docker ps --filter "name=roboco"
# View orchestrator logs
docker compose logs -f orchestrator
# View agent logs
docker logs -f roboco-agent-main-pm
```
## Data Persistence
All data is persisted to the host:
| Container Path | Host Path |
|----------------|-----------|
| postgres data | `./data/postgres/` |
| redis data | `./data/redis/` |
| MCP configs | `./data/mcp-configs/` |
For NAS RAID protection, set `ROBOCO_DATA_DIR` to your RAID volume:
```bash
ROBOCO_DATA_DIR=/volume1/roboco/data
```
## Troubleshooting
### Orchestrator can't spawn agents
```bash
# Check Docker socket is mounted
docker compose logs orchestrator | grep -i docker
# Verify host paths are set correctly
docker compose exec orchestrator env | grep ROBOCO_HOST
# Check if agent image was built
docker images | grep roboco-agent
```
### Agent containers exit immediately
```bash
# Check Claude auth is mounted
docker logs roboco-agent-main-pm
# Verify ~/.claude exists on host
ls -la ~/.claude/
# Re-authenticate if needed
claude
```
### Database connection failed
```bash
docker compose ps postgres
docker compose logs postgres
```
### API not responding
```bash
curl http://localhost:8000/health
docker compose logs orchestrator
```
## Stopping
```bash
# Stop all services
docker compose down
# Stop all agent containers (if needed)
docker ps --filter "name=roboco-agent" -q | xargs docker stop
# Full cleanup (removes volumes)
docker compose down -v
```
## Development Mode (Host)
For local development without Docker orchestrator:
```bash
# Start only infrastructure
docker compose up -d postgres redis
# Install Python dependencies
curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync
# Run migrations
uv run alembic upgrade head
# Start orchestrator directly
uv run python -m roboco.cli --spawn main-pm be-dev-1 be-qa
```
+221
View File
@@ -0,0 +1,221 @@
# RoboCo Usage Guide
Operating the AI company after deployment.
## The Organization
18 AI agents organized as a company:
```
CEO (You)
└── Board
├── Product Owner
├── Head of Marketing
└── Auditor
└── Main PM
├── Backend Cell (PM, 2 Devs, QA, Documenter)
├── Frontend Cell (PM, 2 Devs, QA, Documenter)
└── UX/UI Cell (PM, Dev, QA, Documenter)
```
## Agent IDs
| ID | Role | Team |
|----|------|------|
| `main-pm` | Main PM | Management |
| `be-pm` | Cell PM | Backend |
| `be-dev-1`, `be-dev-2` | Developers | Backend |
| `be-qa` | QA | Backend |
| `be-doc` | Documenter | Backend |
| `fe-pm` | Cell PM | Frontend |
| `fe-dev-1`, `fe-dev-2` | Developers | Frontend |
| `fe-qa` | QA | Frontend |
| `fe-doc` | Documenter | Frontend |
| `ux-pm` | Cell PM | UX/UI |
| `ux-dev` | Developer | UX/UI |
| `ux-qa` | QA | UX/UI |
| `ux-doc` | Documenter | UX/UI |
| `product-owner` | Product Owner | Board |
| `head-marketing` | Head of Marketing | Board |
| `auditor` | Auditor | Board |
## Spawning Agents
```bash
# Start with minimal team
uv run python -m roboco.cli --spawn main-pm be-dev-1 be-qa
# Add more agents
uv run python -m roboco.cli --spawn main-pm be-pm be-dev-1 be-dev-2 be-qa
# Full organization
uv run python -m roboco.cli --spawn \
main-pm \
be-pm be-dev-1 be-dev-2 be-qa be-doc \
fe-pm fe-dev-1 fe-dev-2 fe-qa fe-doc \
ux-pm ux-dev ux-qa ux-doc \
product-owner head-marketing auditor
```
## Monitoring Agents
### Check Status
```bash
# Via API
curl http://localhost:8000/api/v1/orchestrator/status | jq
# Via Docker
docker ps --filter "name=roboco-agent"
```
### View Agent Logs
```bash
# Follow specific agent's output
docker logs -f roboco-agent-be-dev-1
# All agent containers
docker ps --filter "name=roboco-agent" --format "{{.Names}}"
```
### Container Management
```bash
# Stop one agent
docker stop roboco-agent-be-dev-1
# Restart an agent
docker restart roboco-agent-be-dev-1
# Stop all agents
docker ps --filter "name=roboco-agent" -q | xargs docker stop
```
## Creating Tasks
```bash
curl -X POST http://localhost:8000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Implement user authentication",
"description": "Add JWT-based auth to the API",
"team": "backend",
"complexity": "medium",
"acceptance_criteria": [
"Users can register",
"Users can login",
"Protected routes require valid JWT"
]
}'
```
## Task Lifecycle
```
pending → claimed → in_progress → verifying → awaiting_qa → awaiting_docs → completed
blocked/paused
```
Agents automatically:
1. Scan for pending tasks (`roboco_task_scan`)
2. Claim tasks they can work on
3. Follow the workflow: UNDERSTAND → PLAN → EXECUTE → VERIFY → NOTES
4. Submit for QA when done
5. Move to next task
## API Endpoints
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Health check |
| `GET /docs` | Swagger UI |
| `GET /api/v1/orchestrator/status` | Agent states |
| `GET /api/v1/tasks` | List tasks |
| `POST /api/v1/tasks` | Create task |
| `GET /api/v1/tasks/{id}` | Task details |
## Viewing the API
Open http://localhost:8000/docs in your browser for the Swagger UI.
## Common Workflows
### Start a Development Session
```bash
# 1. Start infrastructure
docker compose up -d
# 2. Run migrations (if needed)
uv run alembic upgrade head
# 3. Start with a small team
uv run python -m roboco.cli --spawn main-pm be-dev-1 be-qa
```
### Create and Monitor a Task
```bash
# Create task
curl -X POST http://localhost:8000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Fix login bug", "team": "backend", "complexity": "trivial"}'
# Watch agent pick it up
docker logs -f roboco-agent-be-dev-1
```
### Shutdown
```bash
# Stop orchestrator (Ctrl+C in terminal)
# Stop agent containers
docker ps --filter "name=roboco-agent" -q | xargs docker stop
# Stop infrastructure
docker compose down
```
## Tips
### Start Small
Don't spawn all 18 agents at once. Start with:
1. `main-pm` alone - verify spawning works
2. Add `be-dev-1` - verify task claiming
3. Add `be-qa` - verify full workflow
### Check Agent Health
```bash
# Quick status
curl -s http://localhost:8000/api/v1/orchestrator/status | jq '.agents'
# Detailed container info
docker inspect roboco-agent-be-dev-1
```
### Debug an Agent
```bash
# View full logs
docker logs roboco-agent-be-dev-1
# Attach to container (read-only)
docker logs -f roboco-agent-be-dev-1
```
### Resource Usage
Each agent container uses ~500MB-2GB RAM depending on context. With 128GB RAM:
- 3 agents: ~6GB
- 6 agents: ~12GB
- 18 agents: ~36GB
Monitor with:
```bash
docker stats --filter "name=roboco-agent"
```
+16
View File
@@ -40,6 +40,10 @@ class Settings(BaseSettings):
# ========================================================================== # ==========================================================================
host: str = Field(default="127.0.0.1", description="Use 0.0.0.0 for containers") host: str = Field(default="127.0.0.1", description="Use 0.0.0.0 for containers")
port: int = 8000 port: int = 8000
api_url: str | None = Field(
default=None,
description="Override API URL for containerized agents (e.g., http://roboco-orchestrator:8000)",
)
reload: bool = Field(default=True, description="Auto-reload on code changes") reload: bool = Field(default=True, description="Auto-reload on code changes")
workers: int = Field(default=1, ge=1) workers: int = Field(default=1, ge=1)
@@ -49,6 +53,18 @@ class Settings(BaseSettings):
) )
cors_allow_credentials: bool = True cors_allow_credentials: bool = True
@computed_field # type: ignore[prop-decorator]
@property
def internal_api_url(self) -> str:
"""
Internal API base URL for service-to-service communication.
Uses api_url if set (for containerized agents), otherwise builds from host/port.
"""
if self.api_url:
return f"{self.api_url.rstrip('/')}/api/v1"
return f"http://{self.host}:{self.port}/api/v1"
# ========================================================================== # ==========================================================================
# Database # Database
# ========================================================================== # ==========================================================================
+64 -36
View File
@@ -91,10 +91,10 @@ class AgentTable(Base):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
) )
# Relationships # Relationships
@@ -154,15 +154,23 @@ class TaskTable(Base):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
claimed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
target_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
) )
claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
target_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Planning (stored as JSON) # Planning (stored as JSON)
plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -252,14 +260,16 @@ class ChannelTable(Base):
# Statistics # Statistics
message_count: Mapped[int] = mapped_column(Integer, default=0) message_count: Mapped[int] = mapped_column(Integer, default=0)
group_count: Mapped[int] = mapped_column(Integer, default=0) group_count: Mapped[int] = mapped_column(Integer, default=0)
last_activity: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_activity: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
) )
# Relationships # Relationships
@@ -311,14 +321,16 @@ class GroupTable(Base):
# Statistics # Statistics
total_sessions: Mapped[int] = mapped_column(Integer, default=0) total_sessions: Mapped[int] = mapped_column(Integer, default=0)
total_messages: Mapped[int] = mapped_column(Integer, default=0) total_messages: Mapped[int] = mapped_column(Integer, default=0)
last_activity: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_activity: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
) )
# Relationships # Relationships
@@ -372,12 +384,14 @@ class SessionTable(Base):
# Timestamps # Timestamps
started_at: Mapped[datetime] = mapped_column( started_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
last_activity_at: Mapped[datetime] = mapped_column( last_activity_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
) )
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Statistics # Statistics
message_count: Mapped[int] = mapped_column(Integer, default=0) message_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -385,7 +399,7 @@ class SessionTable(Base):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
# Relationships # Relationships
@@ -464,7 +478,7 @@ class MessageTable(Base):
# Metadata # Metadata
timestamp: Mapped[datetime] = mapped_column( timestamp: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False, index=True DateTime(timezone=True), default=datetime.now(UTC), nullable=False, index=True
) )
# Extraction metadata # Extraction metadata
@@ -472,12 +486,14 @@ class MessageTable(Base):
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True) raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
# Edit tracking (stored as JSON) # Edit tracking (stored as JSON)
edited_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) edited_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) edit_history: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
# Relationships # Relationships
@@ -545,9 +561,11 @@ class NotificationTable(Base):
# Timing # Timing
timestamp: Mapped[datetime] = mapped_column( timestamp: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False, index=True DateTime(timezone=True), default=datetime.now(UTC), nullable=False, index=True
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
) )
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Read tracking # Read tracking
read_by: Mapped[list[PyUUID]] = mapped_column( read_by: Mapped[list[PyUUID]] = mapped_column(
@@ -555,11 +573,13 @@ class NotificationTable(Base):
) )
# Delivery tracking # Delivery tracking
delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) delivered_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
# Relationships # Relationships
@@ -592,21 +612,25 @@ class JournalTable(Base):
# Metadata # Metadata
total_entries: Mapped[int] = mapped_column(Integer, default=0) total_entries: Mapped[int] = mapped_column(Integer, default=0)
last_entry_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_entry_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Summary # Summary
latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True) latest_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
summary_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) summary_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Growth metrics (stored as JSON) # Growth metrics (stored as JSON)
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict) entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
) )
# Relationships # Relationships
@@ -651,7 +675,7 @@ class JournalEntryTable(Base):
# Metadata # Metadata
timestamp: Mapped[datetime] = mapped_column( timestamp: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False, index=True DateTime(timezone=True), default=datetime.now(UTC), nullable=False, index=True
) )
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
@@ -663,10 +687,10 @@ class JournalEntryTable(Base):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
) )
# Relationships # Relationships
@@ -750,13 +774,17 @@ class HandoffTable(Base):
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False DateTime(timezone=True), default=datetime.now(UTC), nullable=False
) )
updated_at: Mapped[datetime | None] = mapped_column( updated_at: Mapped[datetime | None] = mapped_column(
DateTime, onupdate=datetime.now(UTC), nullable=True DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
claimed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
) )
claimed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Documenter feedback # Documenter feedback
documenter_notes: Mapped[str | None] = mapped_column(Text, nullable=True) documenter_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+5 -10
View File
@@ -39,11 +39,6 @@ _toon = ToonAdapter()
# ============================================================================= # =============================================================================
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
def _format_error_response( def _format_error_response(
error_code: str, error_code: str,
message: str, message: str,
@@ -67,7 +62,7 @@ async def _post_journal_entry(
"""Post to a journal endpoint. Returns (data, error).""" """Post to a journal endpoint. Returns (data, error)."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
f"{_get_api_url()}/journals/me/{endpoint}", f"{settings.internal_api_url}/journals/me/{endpoint}",
json=payload, json=payload,
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -224,7 +219,7 @@ async def _handle_search(query: str, top_k: int, agent_id: str) -> dict[str, Any
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
payload = {"query": query, "top_k": min(top_k, 20)} payload = {"query": query, "top_k": min(top_k, 20)}
resp = await client.post( resp = await client.post(
f"{_get_api_url()}/journals/me/search", f"{settings.internal_api_url}/journals/me/search",
json=payload, json=payload,
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -253,11 +248,11 @@ async def _handle_stats(agent_id: str) -> dict[str, Any]:
"""Handle journal stats retrieval.""" """Handle journal stats retrieval."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
stats_resp = await client.get( stats_resp = await client.get(
f"{_get_api_url()}/journals/me/stats", f"{settings.internal_api_url}/journals/me/stats",
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
growth_resp = await client.get( growth_resp = await client.get(
f"{_get_api_url()}/journals/me/growth", f"{settings.internal_api_url}/journals/me/growth",
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -302,7 +297,7 @@ async def _handle_recent(
params["task_id"] = task_id params["task_id"] = task_id
resp = await client.get( resp = await client.get(
f"{_get_api_url()}/journals/me/entries", f"{settings.internal_api_url}/journals/me/entries",
params=params, params=params,
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
+9 -12
View File
@@ -52,11 +52,6 @@ def _check_channel_access(agent_id: str, channel_slug: str, action: str) -> bool
return bool(action == "read" and agent_id in channel.get("silent", [])) return bool(action == "read" and agent_id in channel.get("silent", []))
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
def _format_error_response( def _format_error_response(
error_code: str, error_code: str,
message: str, message: str,
@@ -122,13 +117,15 @@ async def _get_or_create_session(
channel_id: str, channel_id: str,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Get or create session for channel. Returns session_id or error dict.""" """Get or create session for channel. Returns session_id or error dict."""
session_resp = await client.get(f"{_get_api_url()}/channels/{channel_id}/session") session_resp = await client.get(
f"{settings.internal_api_url}/channels/{channel_id}/session"
)
if session_resp.status_code == status.HTTP_200_OK: if session_resp.status_code == status.HTTP_200_OK:
return str(session_resp.json()["id"]) return str(session_resp.json()["id"])
create_resp = await client.post( create_resp = await client.post(
f"{_get_api_url()}/sessions", f"{settings.internal_api_url}/sessions",
json={"channel_id": channel_id}, json={"channel_id": channel_id},
) )
if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]: if create_resp.status_code in [status.HTTP_200_OK, status.HTTP_201_CREATED]:
@@ -184,7 +181,7 @@ async def _handle_channel_history(
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
channels_resp = await client.get( channels_resp = await client.get(
f"{_get_api_url()}/channels", f"{settings.internal_api_url}/channels",
params={"slug": channel_slug}, params={"slug": channel_slug},
) )
@@ -200,7 +197,7 @@ async def _handle_channel_history(
channel_id = channels[0]["id"] channel_id = channels[0]["id"]
messages_resp = await client.get( messages_resp = await client.get(
f"{_get_api_url()}/channels/{channel_id}/messages", f"{settings.internal_api_url}/channels/{channel_id}/messages",
params={"after": since.isoformat(), "limit": limit}, params={"after": since.isoformat(), "limit": limit},
) )
@@ -230,7 +227,7 @@ async def _handle_message_send(
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
channels_resp = await client.get( channels_resp = await client.get(
f"{_get_api_url()}/channels", f"{settings.internal_api_url}/channels",
params={"slug": data.channel_slug}, params={"slug": data.channel_slug},
) )
@@ -258,7 +255,7 @@ async def _handle_message_send(
} }
send_resp = await client.post( send_resp = await client.post(
f"{_get_api_url()}/messages", f"{settings.internal_api_url}/messages",
json=message_data, json=message_data,
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -279,7 +276,7 @@ async def _handle_message_send(
async def _handle_message_get(message_id: str) -> dict[str, Any]: async def _handle_message_get(message_id: str) -> dict[str, Any]:
"""Handle message retrieval.""" """Handle message retrieval."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/messages/{message_id}") resp = await client.get(f"{settings.internal_api_url}/messages/{message_id}")
if resp.status_code == status.HTTP_404_NOT_FOUND: if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response( return _format_error_response(
+4 -9
View File
@@ -60,11 +60,6 @@ def _can_send_notification(sender_id: str, recipient_id: str) -> tuple[bool, str
return False, f"You cannot send notifications to {recipient_id}" return False, f"You cannot send notifications to {recipient_id}"
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
def _format_error_response( def _format_error_response(
error_code: str, error_code: str,
message: str, message: str,
@@ -100,7 +95,7 @@ async def _handle_list(
} }
resp = await client.get( resp = await client.get(
f"{_get_api_url()}/notifications", f"{settings.internal_api_url}/notifications",
params=params, params=params,
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -137,7 +132,7 @@ async def _handle_get(agent_id: str, notification_id: str) -> dict[str, Any]:
"""Handle getting a specific notification.""" """Handle getting a specific notification."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
f"{_get_api_url()}/notifications/{notification_id}", f"{settings.internal_api_url}/notifications/{notification_id}",
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -168,7 +163,7 @@ async def _handle_ack(agent_id: str, notification_id: str) -> dict[str, Any]:
"""Handle acknowledging a notification.""" """Handle acknowledging a notification."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
f"{_get_api_url()}/notifications/{notification_id}/ack", f"{settings.internal_api_url}/notifications/{notification_id}/ack",
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
@@ -249,7 +244,7 @@ async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str,
} }
resp = await client.post( resp = await client.post(
f"{_get_api_url()}/notifications", f"{settings.internal_api_url}/notifications",
json=payload, json=payload,
headers={"X-Agent-Id": agent_id}, headers={"X-Agent-Id": agent_id},
) )
+84 -42
View File
@@ -35,16 +35,6 @@ _toon = ToonAdapter()
# NOTE: For task lifecycle validation, use enforcement.task_lifecycle.VALID_TRANSITIONS # NOTE: For task lifecycle validation, use enforcement.task_lifecycle.VALID_TRANSITIONS
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
def _format_task_response( def _format_task_response(
task: dict[str, Any], task: dict[str, Any],
next_step: str, next_step: str,
@@ -158,7 +148,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Get paused tasks for this agent # Get paused tasks for this agent
paused_resp = await client.get( paused_resp = await client.get(
f"{_get_api_url()}/tasks", f"{settings.internal_api_url}/tasks",
params={"assigned_to": agent_id, "status": "paused"}, params={"assigned_to": agent_id, "status": "paused"},
) )
paused_tasks = ( paused_tasks = (
@@ -167,7 +157,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
# Get assigned tasks (claimed, in_progress) # Get assigned tasks (claimed, in_progress)
assigned_resp = await client.get( assigned_resp = await client.get(
f"{_get_api_url()}/tasks", f"{settings.internal_api_url}/tasks",
params={"assigned_to": agent_id}, params={"assigned_to": agent_id},
) )
assigned_data = ( assigned_data = (
@@ -187,7 +177,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
if team: if team:
params["team"] = team params["team"] = team
available_resp = await client.get( available_resp = await client.get(
f"{_get_api_url()}/tasks", f"{settings.internal_api_url}/tasks",
params=params, params=params,
) )
available_tasks = ( available_tasks = (
@@ -229,7 +219,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
async def _handle_task_get(task_id: str) -> dict[str, Any]: async def _handle_task_get(task_id: str) -> dict[str, Any]:
"""Handle getting task details.""" """Handle getting task details."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if resp.status_code == status.HTTP_404_NOT_FOUND: if resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response( return _format_error_response(
@@ -285,7 +275,7 @@ def _validate_task_claimable(task: dict) -> dict[str, Any] | None:
async def _get_project_context(project_id: str) -> dict[str, Any] | None: async def _get_project_context(project_id: str) -> dict[str, Any] | None:
"""Fetch project context if available.""" """Fetch project context if available."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(f"{_get_api_url()}/projects/{project_id}") resp = await client.get(f"{settings.internal_api_url}/projects/{project_id}")
if resp.status_code == status.HTTP_200_OK: if resp.status_code == status.HTTP_200_OK:
result: dict[str, Any] = resp.json() result: dict[str, Any] = resp.json()
return result return result
@@ -296,7 +286,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
"""Handle task claiming.""" """Handle task claiming."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
active_resp = await client.get( active_resp = await client.get(
f"{_get_api_url()}/tasks", f"{settings.internal_api_url}/tasks",
params={"assigned_to": agent_id}, params={"assigned_to": agent_id},
) )
if active_resp.status_code == status.HTTP_200_OK: if active_resp.status_code == status.HTTP_200_OK:
@@ -306,7 +296,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
if error := _check_paused_tasks(active_tasks): if error := _check_paused_tasks(active_tasks):
return error return error
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -315,7 +305,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
return error return error
claim_resp = await client.post( claim_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/claim", f"{settings.internal_api_url}/tasks/{task_id}/claim",
json={"agent_id": agent_id}, json={"agent_id": agent_id},
) )
if claim_resp.status_code != status.HTTP_200_OK: if claim_resp.status_code != status.HTTP_200_OK:
@@ -392,7 +382,7 @@ async def _handle_task_plan(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle task planning.""" """Handle task planning."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -404,7 +394,7 @@ async def _handle_task_plan(
plan_data = _build_plan_data(plan_params) plan_data = _build_plan_data(plan_params)
update_resp = await client.patch( update_resp = await client.patch(
f"{_get_api_url()}/tasks/{task_id}", f"{settings.internal_api_url}/tasks/{task_id}",
json={"plan": plan_data}, json={"plan": plan_data},
) )
if update_resp.status_code != status.HTTP_200_OK: if update_resp.status_code != status.HTTP_200_OK:
@@ -470,7 +460,7 @@ def _validate_task_start(task: dict[str, Any], agent_id: str) -> dict[str, Any]
async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]: async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
"""Handle task start.""" """Handle task start."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -480,7 +470,9 @@ async def _handle_task_start(task_id: str, agent_id: str) -> dict[str, Any]:
return validation_error return validation_error
# Start the task # Start the task
start_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/start") start_resp = await client.post(
f"{settings.internal_api_url}/tasks/{task_id}/start"
)
if start_resp.status_code != status.HTTP_200_OK: if start_resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response(
@@ -509,7 +501,7 @@ async def _handle_task_progress(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle task progress update.""" """Handle task progress update."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -528,7 +520,7 @@ async def _handle_task_progress(
# Add progress update # Add progress update
progress_resp = await client.post( progress_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/progress", f"{settings.internal_api_url}/tasks/{task_id}/progress",
json={ json={
"agent_id": agent_id, "agent_id": agent_id,
"message": message, "message": message,
@@ -566,7 +558,7 @@ async def _handle_task_block(
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -585,7 +577,7 @@ async def _handle_task_block(
# Block the task # Block the task
block_resp = await client.post( block_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/block", f"{settings.internal_api_url}/tasks/{task_id}/block",
json={ json={
"reason": reason, "reason": reason,
"blocker_type": blocker_type, "blocker_type": blocker_type,
@@ -614,7 +606,7 @@ async def _handle_task_block(
async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]: async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
"""Handle task unblocking.""" """Handle task unblocking."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -631,7 +623,9 @@ async def _handle_task_unblock(task_id: str, agent_id: str) -> dict[str, Any]:
"Task is not blocked", "Task is not blocked",
) )
unblock_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/unblock") unblock_resp = await client.post(
f"{settings.internal_api_url}/tasks/{task_id}/unblock"
)
if unblock_resp.status_code != status.HTTP_200_OK: if unblock_resp.status_code != status.HTTP_200_OK:
return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task") return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
@@ -654,7 +648,7 @@ async def _handle_task_pause(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle task pausing.""" """Handle task pausing."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -673,7 +667,7 @@ async def _handle_task_pause(
# Add checkpoint # Add checkpoint
await client.post( await client.post(
f"{_get_api_url()}/tasks/{task_id}/checkpoint", f"{settings.internal_api_url}/tasks/{task_id}/checkpoint",
json={ json={
"agent_id": agent_id, "agent_id": agent_id,
"state_summary": checkpoint_summary, "state_summary": checkpoint_summary,
@@ -683,7 +677,9 @@ async def _handle_task_pause(
) )
# Pause the task # Pause the task
pause_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/pause") pause_resp = await client.post(
f"{settings.internal_api_url}/tasks/{task_id}/pause"
)
if pause_resp.status_code != status.HTTP_200_OK: if pause_resp.status_code != status.HTTP_200_OK:
return _format_error_response("PAUSE_FAILED", "Failed to pause task") return _format_error_response("PAUSE_FAILED", "Failed to pause task")
@@ -705,7 +701,7 @@ async def _handle_task_submit_verification(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle task verification submission.""" """Handle task verification submission."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -731,7 +727,9 @@ async def _handle_task_submit_verification(
"before verification.", "before verification.",
) )
verify_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/verify") verify_resp = await client.post(
f"{settings.internal_api_url}/tasks/{task_id}/verify"
)
if verify_resp.status_code != status.HTTP_200_OK: if verify_resp.status_code != status.HTTP_200_OK:
return _format_error_response( return _format_error_response(
@@ -768,7 +766,7 @@ async def _handle_task_submit_qa(
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -787,7 +785,7 @@ async def _handle_task_submit_qa(
# Update with notes # Update with notes
await client.patch( await client.patch(
f"{_get_api_url()}/tasks/{task_id}", f"{settings.internal_api_url}/tasks/{task_id}",
json={ json={
"dev_notes": dev_notes, "dev_notes": dev_notes,
"documenter_handoff": handoff_summary, "documenter_handoff": handoff_summary,
@@ -795,7 +793,9 @@ async def _handle_task_submit_qa(
) )
# Submit for QA # Submit for QA
qa_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/submit-qa") qa_resp = await client.post(
f"{settings.internal_api_url}/tasks/{task_id}/submit-qa"
)
if qa_resp.status_code != status.HTTP_200_OK: if qa_resp.status_code != status.HTTP_200_OK:
return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA") return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA")
@@ -825,7 +825,7 @@ async def _handle_task_qa_pass(
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -845,7 +845,7 @@ async def _handle_task_qa_pass(
) )
pass_resp = await client.post( pass_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/pass-qa", f"{settings.internal_api_url}/tasks/{task_id}/pass-qa",
json={"notes": qa_notes}, json={"notes": qa_notes},
) )
@@ -882,7 +882,7 @@ async def _handle_task_qa_fail(
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -897,7 +897,7 @@ async def _handle_task_qa_fail(
full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(f"- {i}" for i in issues) full_notes = f"{qa_notes}\n\nIssues:\n" + "\n".join(f"- {i}" for i in issues)
fail_resp = await client.post( fail_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/fail-qa", f"{settings.internal_api_url}/tasks/{task_id}/fail-qa",
json={"notes": full_notes}, json={"notes": full_notes},
) )
@@ -918,7 +918,7 @@ async def _handle_task_qa_fail(
async def _handle_task_complete(task_id: str) -> dict[str, Any]: async def _handle_task_complete(task_id: str) -> dict[str, Any]:
"""Handle task completion.""" """Handle task completion."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
task_resp = await client.get(f"{_get_api_url()}/tasks/{task_id}") task_resp = await client.get(f"{settings.internal_api_url}/tasks/{task_id}")
if task_resp.status_code == status.HTTP_404_NOT_FOUND: if task_resp.status_code == status.HTTP_404_NOT_FOUND:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found") return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -930,7 +930,9 @@ async def _handle_task_complete(task_id: str) -> dict[str, Any]:
"Task must be awaiting documentation to complete", "Task must be awaiting documentation to complete",
) )
complete_resp = await client.post(f"{_get_api_url()}/tasks/{task_id}/complete") complete_resp = await client.post(
f"{settings.internal_api_url}/tasks/{task_id}/complete"
)
if complete_resp.status_code != status.HTTP_200_OK: if complete_resp.status_code != status.HTTP_200_OK:
return _format_error_response("COMPLETE_FAILED", "Failed to complete task") return _format_error_response("COMPLETE_FAILED", "Failed to complete task")
@@ -944,6 +946,32 @@ async def _handle_task_complete(task_id: str) -> dict[str, Any]:
) )
async def _handle_agent_idle(agent_id: str) -> dict[str, Any]:
"""Handle agent going idle (no work available)."""
async with httpx.AsyncClient() as client:
# Signal to orchestrator that this agent is idle
resp = await client.post(
f"{settings.internal_api_url}/orchestrator/agents/{agent_id}/mark-waiting",
params={"waiting_for": "task_assignment"},
)
if resp.status_code == status.HTTP_204_NO_CONTENT:
return {
"status": "idle",
"message": (
"You are now in WAITING state. Your container will terminate "
"to save resources. You will be respawned when work is available."
),
"action": "EXIT_GRACEFULLY",
}
return _format_error_response(
"IDLE_FAILED",
"Failed to signal idle state to orchestrator",
{"status_code": resp.status_code},
)
# ============================================================================= # =============================================================================
# MCP SERVER FACTORY # MCP SERVER FACTORY
# ============================================================================= # =============================================================================
@@ -1262,6 +1290,20 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
""" """
return await _handle_task_complete(task_id) return await _handle_task_complete(task_id)
@mcp.tool()
async def roboco_agent_idle() -> dict[str, Any]:
"""
Signal that you have no work and should go idle.
Call this when roboco_task_scan returns no tasks.
Your container will be terminated to save resources.
You will be automatically respawned when new work is available.
Returns:
Confirmation of idle state
"""
return await _handle_agent_idle(agent_id)
return mcp return mcp
+2 -3
View File
@@ -4,7 +4,6 @@ Runtime Models
Domain types for the agent orchestrator system. Domain types for the agent orchestrator system.
""" """
import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -38,12 +37,12 @@ class OrchestratorAgentConfig:
@dataclass @dataclass
class AgentInstance: class AgentInstance:
"""A running Claude Code agent instance.""" """A running Claude Code agent instance (Docker container)."""
id: UUID = field(default_factory=uuid4) id: UUID = field(default_factory=uuid4)
agent_id: str = "" agent_id: str = ""
state: OrchestratorAgentState = OrchestratorAgentState.OFFLINE state: OrchestratorAgentState = OrchestratorAgentState.OFFLINE
process: asyncio.subprocess.Process | None = None container_id: str | None = None # Docker container ID
config: OrchestratorAgentConfig | None = None config: OrchestratorAgentConfig | None = None
started_at: datetime | None = None started_at: datetime | None = None
last_activity: datetime | None = None last_activity: datetime | None = None
+221 -70
View File
@@ -1,7 +1,7 @@
""" """
Agent Orchestrator Agent Orchestrator
Manages Claude Code instances for all RoboCo agents. Manages Claude Code containers for all RoboCo agents.
Handles spawning, monitoring, health checks, and graceful shutdown. Handles spawning, monitoring, health checks, and graceful shutdown.
""" """
@@ -31,6 +31,19 @@ logger = structlog.get_logger()
AgentState = OrchestratorAgentState AgentState = OrchestratorAgentState
AgentConfig = OrchestratorAgentConfig AgentConfig = OrchestratorAgentConfig
# Docker configuration
AGENT_IMAGE = "roboco-agent"
AGENT_NETWORK = "roboco_default"
# When running in a container, we need host paths for volume mounts.
# These can be overridden via environment variables.
CLAUDE_AUTH_HOST_PATH = os.environ.get(
"ROBOCO_HOST_CLAUDE_DIR",
str(Path.home() / ".claude"),
)
PROJECT_HOST_PATH = os.environ.get("ROBOCO_HOST_PROJECT_DIR", "")
DATA_HOST_PATH = os.environ.get("ROBOCO_HOST_DATA_DIR", "")
# ============================================================================= # =============================================================================
# ORCHESTRATOR # ORCHESTRATOR
@@ -39,11 +52,11 @@ AgentConfig = OrchestratorAgentConfig
class AgentOrchestrator: class AgentOrchestrator:
""" """
Manages Claude Code instances for all agents. Manages Claude Code containers for all agents.
Responsibilities: Responsibilities:
- Spawn agents with correct blueprints - Spawn agents as Docker containers
- Monitor health (heartbeat, errors) - Monitor health via docker inspect
- Handle waiting states and respawning - Handle waiting states and respawning
- Provide status API - Provide status API
- Cost-efficient on-demand spawning - Cost-efficient on-demand spawning
@@ -53,9 +66,11 @@ class AgentOrchestrator:
self, self,
blueprints_dir: Path | None = None, blueprints_dir: Path | None = None,
mcp_config_dir: Path | None = None, mcp_config_dir: Path | None = None,
project_root: Path | None = None,
): ):
self.blueprints_dir = blueprints_dir or Path("agents/blueprints") self.blueprints_dir = blueprints_dir or Path("agents/blueprints")
self.mcp_config_dir = mcp_config_dir or Path(".mcp") self.mcp_config_dir = mcp_config_dir or Path(".mcp")
self.project_root = project_root or Path.cwd()
self._instances: dict[str, AgentInstance] = {} self._instances: dict[str, AgentInstance] = {}
self._waiting_records: dict[str, WaitingRecord] = {} self._waiting_records: dict[str, WaitingRecord] = {}
@@ -70,6 +85,10 @@ class AgentOrchestrator:
async def start(self) -> None: async def start(self) -> None:
"""Start the orchestrator.""" """Start the orchestrator."""
self._running = True self._running = True
# Ensure agent image is built
await self._ensure_agent_image()
self._health_task = asyncio.create_task(self._health_loop()) self._health_task = asyncio.create_task(self._health_loop())
logger.info("Orchestrator started") logger.info("Orchestrator started")
@@ -88,6 +107,42 @@ class AgentOrchestrator:
logger.info("Orchestrator stopped") logger.info("Orchestrator stopped")
async def _ensure_agent_image(self) -> None:
"""Ensure the agent Docker image is built."""
# Check if image exists
proc = await asyncio.create_subprocess_exec(
"docker", "image", "inspect", AGENT_IMAGE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
if proc.returncode != 0:
logger.info("Building agent Docker image...")
# Determine paths - use host paths when running in container
if PROJECT_HOST_PATH:
# Running in container - use host paths for Docker build
dockerfile_path = f"{PROJECT_HOST_PATH}/docker/agent.Dockerfile"
build_context = PROJECT_HOST_PATH
else:
# Running on host
dockerfile_path = str(self.project_root / "docker" / "agent.Dockerfile")
build_context = str(self.project_root)
proc = await asyncio.create_subprocess_exec(
"docker", "build",
"-t", AGENT_IMAGE,
"-f", dockerfile_path,
build_context,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to build agent image: {stderr.decode()}")
logger.info("Agent Docker image built successfully")
# ========================================================================= # =========================================================================
# AGENT SPAWNING # AGENT SPAWNING
# ========================================================================= # =========================================================================
@@ -100,7 +155,7 @@ class AgentOrchestrator:
model: str | None = None, model: str | None = None,
) -> AgentInstance: ) -> AgentInstance:
""" """
Spawn a Claude Code instance for an agent. Spawn a Claude Code container for an agent.
Args: Args:
agent_id: Agent identifier (e.g., "be-dev-1") agent_id: Agent identifier (e.g., "be-dev-1")
@@ -154,10 +209,10 @@ class AgentOrchestrator:
self._instances[agent_id] = instance self._instances[agent_id] = instance
# Spawn the process # Spawn the container
try: try:
process = await self._spawn_process(config, initial_prompt) container_id = await self._spawn_container(config, initial_prompt)
instance.process = process instance.container_id = container_id
instance.state = AgentState.ACTIVE instance.state = AgentState.ACTIVE
instance.started_at = datetime.now(UTC) instance.started_at = datetime.now(UTC)
instance.last_activity = datetime.now(UTC) instance.last_activity = datetime.now(UTC)
@@ -165,6 +220,7 @@ class AgentOrchestrator:
logger.info( logger.info(
"Agent spawned", "Agent spawned",
agent_id=agent_id, agent_id=agent_id,
container_id=container_id[:12],
model=model, model=model,
task_id=task_id, task_id=task_id,
) )
@@ -181,90 +237,149 @@ class AgentOrchestrator:
) )
raise raise
async def _spawn_process( async def _spawn_container(
self, self,
config: AgentConfig, config: AgentConfig,
initial_prompt: str | None = None, initial_prompt: str | None = None,
) -> asyncio.subprocess.Process: ) -> str:
"""Spawn the actual Claude Code process.""" """Spawn a Docker container for the agent."""
container_name = f"roboco-agent-{config.agent_id}"
# Remove existing container if any
await self._remove_container(container_name)
# Determine host paths for volume mounts
# When running in a container, use PROJECT_HOST_PATH; otherwise use local paths
if not config.mcp_config_path:
raise RuntimeError("MCP config path not set")
if PROJECT_HOST_PATH:
# Running inside orchestrator container - use host paths
blueprints_host = f"{PROJECT_HOST_PATH}/agents/blueprints"
claude_host = CLAUDE_AUTH_HOST_PATH
mcp_config_host = (
f"{DATA_HOST_PATH}/mcp-configs/{config.mcp_config_path.name}"
)
else:
# Running directly on host
blueprints_host = str(self.blueprints_dir.absolute())
claude_host = CLAUDE_AUTH_HOST_PATH
mcp_config_host = str(config.mcp_config_path)
# Build docker run command
cmd = [ cmd = [
"claude", "docker",
"run",
"-d",
"--name",
container_name,
"--network",
AGENT_NETWORK,
# Mount Claude auth (needs write access for debug logs)
"-v",
f"{claude_host}:/home/agent/.claude",
# Mount blueprints
"-v",
f"{blueprints_host}:/app/agents/blueprints:ro",
# Mount MCP config
"-v",
f"{mcp_config_host}:/app/mcp-config.json:ro",
# Environment
"-e",
f"ROBOCO_AGENT_ID={config.agent_id}",
"-e",
"ROBOCO_API_URL=http://roboco-orchestrator:8000",
# The image
AGENT_IMAGE,
# Claude Code arguments
"--model", "--model",
MODEL_MAP.get(config.model, config.model), MODEL_MAP.get(config.model, config.model),
"--system-prompt-file", "--system-prompt-file",
str(config.blueprint_path), f"/app/agents/blueprints/{self._get_blueprint_rel_path(config.agent_id)}",
"--mcp-config",
"/app/mcp-config.json",
"--output-format", "--output-format",
"stream-json", "stream-json",
"--verbose",
# Always provide a prompt (required for non-interactive mode)
"-p",
initial_prompt or (
"You are now online. Run roboco_task_scan() to check for work. "
"If no tasks are available, call roboco_agent_idle() to go into "
"waiting state and conserve resources."
),
] ]
if config.mcp_config_path: proc = await asyncio.create_subprocess_exec(
cmd.extend(["--mcp-config", str(config.mcp_config_path)])
if initial_prompt:
cmd.extend(["-p", initial_prompt])
env = os.environ.copy()
env["ROBOCO_AGENT_ID"] = config.agent_id
process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=env,
cwd=config.working_directory,
) )
stdout, stderr = await proc.communicate()
return process if proc.returncode != 0:
raise RuntimeError(f"Failed to start container: {stderr.decode()}")
container_id = stdout.decode().strip()
return container_id
async def _remove_container(self, container_name: str) -> None:
"""Remove a container if it exists."""
proc = await asyncio.create_subprocess_exec(
"docker", "rm", "-f", container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
async def _generate_mcp_config(self, agent_id: str) -> Path: async def _generate_mcp_config(self, agent_id: str) -> Path:
"""Generate MCP config for an agent with embedded agent_id.""" """Generate MCP config for an agent."""
# MCP servers run inside the container, connect to API via network
config = { config = {
"mcpServers": { "mcpServers": {
"roboco-task": { "roboco-task": {
"command": "python", "command": "uv",
"args": [ "args": ["run", "python", "-m", "roboco.mcp.task_server", agent_id],
"-m",
"roboco.mcp.task_server",
agent_id,
],
}, },
"roboco-message": { "roboco-message": {
"command": "python", "command": "uv",
"args": [ "args": [
"-m", "run", "python", "-m", "roboco.mcp.message_server", agent_id
"roboco.mcp.message_server",
agent_id,
], ],
}, },
"roboco-notify": { "roboco-notify": {
"command": "python", "command": "uv",
"args": [ "args": [
"-m", "run", "python", "-m", "roboco.mcp.notify_server", agent_id
"roboco.mcp.notify_server",
agent_id,
], ],
}, },
"roboco-journal": { "roboco-journal": {
"command": "python", "command": "uv",
"args": [ "args": [
"-m", "run", "python", "-m", "roboco.mcp.journal_server", agent_id
"roboco.mcp.journal_server",
agent_id,
], ],
}, },
} }
} }
# Write to temp file # Write to shared config directory (mounted in both orchestrator and agents)
config_path = Path(tempfile.gettempdir()) / f"roboco-mcp-{agent_id}.json" # When running in container: /app/mcp-configs -> host's ./data/mcp-configs
# When running on host: use temp directory
if DATA_HOST_PATH:
# Running in container - use shared mounted directory
config_dir = Path("/app/mcp-configs")
config_dir.mkdir(parents=True, exist_ok=True)
else:
# Running on host - use temp directory
config_dir = Path(tempfile.gettempdir())
config_path = config_dir / f"roboco-mcp-{agent_id}.json"
config_path.write_text(json.dumps(config, indent=2)) config_path.write_text(json.dumps(config, indent=2))
return config_path return config_path
def _get_blueprint_path(self, agent_id: str) -> Path: def _get_blueprint_path(self, agent_id: str) -> Path:
"""Get blueprint path for an agent.""" """Get blueprint path for an agent."""
# Map agent_id to blueprint
role = self._get_agent_role(agent_id) role = self._get_agent_role(agent_id)
team = self._get_agent_team(agent_id) team = self._get_agent_team(agent_id)
@@ -278,9 +393,25 @@ class AgentOrchestrator:
cell_dir = "board" cell_dir = "board"
blueprint_file = f"{role.replace('_', '-')}.md" blueprint_file = f"{role.replace('_', '-')}.md"
return self.blueprints_dir / cell_dir / blueprint_file return self.blueprints_dir / cell_dir / blueprint_file
def _get_blueprint_rel_path(self, agent_id: str) -> str:
"""Get relative blueprint path for container mount."""
role = self._get_agent_role(agent_id)
team = self._get_agent_team(agent_id)
if team == "backend":
cell_dir = "backend"
elif team == "frontend":
cell_dir = "frontend"
elif team == "uxui":
cell_dir = "ux_ui"
else:
cell_dir = "board"
blueprint_file = f"{role.replace('_', '-')}.md"
return f"{cell_dir}/{blueprint_file}"
def _get_agent_role(self, agent_id: str) -> str: def _get_agent_role(self, agent_id: str) -> str:
"""Get role from agent_id.""" """Get role from agent_id."""
role_map = { role_map = {
@@ -320,33 +451,39 @@ class AgentOrchestrator:
# ========================================================================= # =========================================================================
async def stop_agent(self, agent_id: str, graceful: bool = True) -> None: async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
"""Stop an agent.""" """Stop an agent container."""
async with self._lock: async with self._lock:
if agent_id not in self._instances: if agent_id not in self._instances:
return return
instance = self._instances[agent_id] instance = self._instances[agent_id]
if instance.process and instance.process.returncode is None: if instance.container_id:
instance.state = AgentState.STOPPING instance.state = AgentState.STOPPING
container_name = f"roboco-agent-{agent_id}"
if graceful: if graceful:
# Send interrupt # Graceful stop with timeout
instance.process.terminate() proc = await asyncio.create_subprocess_exec(
try: "docker", "stop", "-t", "10", container_name,
await asyncio.wait_for( stdout=asyncio.subprocess.DEVNULL,
instance.process.wait(), stderr=asyncio.subprocess.DEVNULL,
timeout=10.0, )
) await proc.wait()
except TimeoutError:
instance.process.kill()
await instance.process.wait()
else: else:
instance.process.kill() # Force kill
await instance.process.wait() proc = await asyncio.create_subprocess_exec(
"docker", "kill", container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
# Remove container
await self._remove_container(container_name)
instance.state = AgentState.OFFLINE instance.state = AgentState.OFFLINE
instance.process = None instance.container_id = None
logger.info("Agent stopped", agent_id=agent_id) logger.info("Agent stopped", agent_id=agent_id)
@@ -501,18 +638,30 @@ Start by:
if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT): if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT):
continue continue
if instance.process is None: if instance.container_id is None:
continue continue
# Check if process died # Check if container is still running
if instance.process.returncode is not None: container_name = f"roboco-agent-{agent_id}"
proc = await asyncio.create_subprocess_exec(
"docker", "inspect", "-f", "{{.State.Running}}", container_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await proc.communicate()
is_running = stdout.decode().strip() == "true"
if not is_running:
cid = instance.container_id[:12] if instance.container_id else None
logger.warning( logger.warning(
"Agent process died", "Agent container stopped",
agent_id=agent_id, agent_id=agent_id,
returncode=instance.process.returncode, container_id=cid,
) )
instance.state = AgentState.OFFLINE instance.state = AgentState.OFFLINE
instance.error_count += 1 instance.error_count += 1
instance.container_id = None
# Auto-restart if not too many errors # Auto-restart if not too many errors
max_retries = 3 max_retries = 3
@@ -556,10 +705,12 @@ Start by:
by_state[state.value] = count by_state[state.value] = count
for agent_id, instance in self._instances.items(): for agent_id, instance in self._instances.items():
cid = instance.container_id[:12] if instance.container_id else None
agents.append( agents.append(
{ {
"agent_id": agent_id, "agent_id": agent_id,
"state": instance.state.value, "state": instance.state.value,
"container_id": cid,
"task_id": instance.current_task_id, "task_id": instance.current_task_id,
"error_count": instance.error_count, "error_count": instance.error_count,
"started_at": instance.started_at.isoformat() "started_at": instance.started_at.isoformat()
+10 -10
View File
@@ -36,25 +36,25 @@ DEFAULT_CHANNELS = [
"slug": "dev-all", "slug": "dev-all",
"name": "All Developers", "name": "All Developers",
"description": "Cross-cell developer discussion", "description": "Cross-cell developer discussion",
"channel_type": "role", "channel_type": "cross_cell",
}, },
{ {
"slug": "qa-all", "slug": "qa-all",
"name": "All QA", "name": "All QA",
"description": "Cross-cell QA discussion", "description": "Cross-cell QA discussion",
"channel_type": "role", "channel_type": "cross_cell",
}, },
{ {
"slug": "pm-all", "slug": "pm-all",
"name": "All PMs", "name": "All PMs",
"description": "Cross-cell PM coordination", "description": "Cross-cell PM coordination",
"channel_type": "role", "channel_type": "cross_cell",
}, },
{ {
"slug": "doc-all", "slug": "doc-all",
"name": "All Documenters", "name": "All Documenters",
"description": "Cross-cell documentation discussion", "description": "Cross-cell documentation discussion",
"channel_type": "role", "channel_type": "cross_cell",
}, },
# Management channels # Management channels
{ {
@@ -74,13 +74,13 @@ DEFAULT_CHANNELS = [
"slug": "announcements", "slug": "announcements",
"name": "Announcements", "name": "Announcements",
"description": "Company-wide announcements (read-only for most)", "description": "Company-wide announcements (read-only for most)",
"channel_type": "broadcast", "channel_type": "special",
}, },
{ {
"slug": "all-hands", "slug": "all-hands",
"name": "All Hands", "name": "All Hands",
"description": "Company-wide open discussion", "description": "Company-wide open discussion",
"channel_type": "broadcast", "channel_type": "special",
}, },
] ]
@@ -137,15 +137,15 @@ DEFAULT_AGENTS: list[dict[str, Any]] = [
"agent_id": "ux-dev", "agent_id": "ux-dev",
"name": "UX/UI Developer", "name": "UX/UI Developer",
"role": "developer", "role": "developer",
"team": "uxui", "team": "ux_ui",
}, },
{"agent_id": "ux-qa", "name": "UX/UI QA", "role": "qa", "team": "uxui"}, {"agent_id": "ux-qa", "name": "UX/UI QA", "role": "qa", "team": "ux_ui"},
{"agent_id": "ux-pm", "name": "UX/UI PM", "role": "cell_pm", "team": "uxui"}, {"agent_id": "ux-pm", "name": "UX/UI PM", "role": "cell_pm", "team": "ux_ui"},
{ {
"agent_id": "ux-doc", "agent_id": "ux-doc",
"name": "UX/UI Documenter", "name": "UX/UI Documenter",
"role": "documenter", "role": "documenter",
"team": "uxui", "team": "ux_ui",
}, },
# Board / Management # Board / Management
{"agent_id": "main-pm", "name": "Main PM", "role": "main_pm", "team": None}, {"agent_id": "main-pm", "name": "Main PM", "role": "main_pm", "team": None},