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
# =============================================================================
# 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
# =============================================================================
ROBOCO_ENVIRONMENT=development
ROBOCO_DEBUG=true
ROBOCO_LOG_LEVEL=INFO
# =============================================================================
# API Server
# =============================================================================
ROBOCO_HOST=0.0.0.0
ROBOCO_PORT=8000
# =============================================================================
# Database (PostgreSQL)
# =============================================================================
# For docker compose deployment, use container name:
# ROBOCO_DATABASE_HOST=roboco-postgres
# For local development:
ROBOCO_DATABASE_HOST=localhost
ROBOCO_DATABASE_PORT=5432
ROBOCO_DATABASE_USER=roboco
@@ -20,23 +54,35 @@ ROBOCO_DATABASE_PASSWORD=roboco
ROBOCO_DATABASE_NAME=roboco
ROBOCO_DATABASE_ECHO=false
# =============================================================================
# Redis
# =============================================================================
# For docker compose deployment, use container name:
# ROBOCO_REDIS_HOST=roboco-redis
# For local development:
ROBOCO_REDIS_HOST=localhost
ROBOCO_REDIS_PORT=6379
ROBOCO_REDIS_DB=0
# ROBOCO_REDIS_PASSWORD=
# Qdrant (Vector DB)
# =============================================================================
# Qdrant (Vector DB) - Optional
# =============================================================================
ROBOCO_QDRANT_HOST=localhost
ROBOCO_QDRANT_PORT=6333
# ROBOCO_QDRANT_API_KEY=
# AI/LLM Providers
ROBOCO_ANTHROPIC_API_KEY=your-anthropic-api-key
ROBOCO_OPENAI_API_KEY=your-openai-api-key
# =============================================================================
# OpenAI (optional, for embeddings)
# =============================================================================
# ROBOCO_OPENAI_API_KEY=your-openai-api-key
# =============================================================================
# Security
# =============================================================================
ROBOCO_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars
# =============================================================================
# CORS (comma-separated origins)
# =============================================================================
ROBOCO_CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
+156 -29
View File
@@ -28,6 +28,119 @@ upgrade:
@uv sync --all-extras
@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
.PHONY: stop
stop:
@@ -254,41 +367,55 @@ clean:
# Help
.PHONY: help
help:
@echo "Available commands:"
@echo "RoboCo - AI Agents Company"
@echo ""
@echo "Infrastructure:"
@echo " make infra - Start PostgreSQL + Redis"
@echo " make infra-down - Stop infrastructure"
@echo " make migrate - Run database migrations"
@echo " make migration - Create new migration"
@echo " make db-init - Initialize/seed database"
@echo ""
@echo "Running:"
@echo " make dev - Start API + Orchestrator (development)"
@echo " make dev AGENTS='a b c' - Start with specific agents"
@echo " make api - Start API only (with reload)"
@echo " make run - Start API only (production)"
@echo " make orchestrator - Start orchestrator only"
@echo " make tmux - Create tmux session with all components"
@echo ""
@echo "Monitoring:"
@echo " make status - Show system status"
@echo " make logs - Tail infrastructure logs"
@echo ""
@echo "Dependencies:"
@echo " make install - Install dependencies"
@echo " make install-dev - Install dev dependencies"
@echo " make lock - Update dependencies"
@echo " make start-example - Start example application with docker compose"
@echo " make run-example - Build and run example container directly"
@echo " make stop - Stop all containers and clean up resources"
@echo " make restart - Restart example application"
@echo " make lint - Run linting checks"
@echo " make lock - Update lock file"
@echo " make upgrade - Upgrade all dependencies"
@echo ""
@echo "Code Quality:"
@echo " make lint - Run linting (ruff, mypy, vulture)"
@echo " make fix - Auto-fix linting issues"
@echo " make vulture - Find dead code with Vulture"
@echo " make bandit - Run Bandit security scan"
@echo " make safety - Check dependencies with Safety"
@echo " make pip-audit - Audit dependencies with pip-audit"
@echo " make radon - Analyze code complexity with Radon"
@echo " make xenon - Check complexity thresholds with Xenon"
@echo " make deptry - Analyze dependencies with Deptry"
@echo " make semgrep - Run Semgrep static analysis"
@echo " make security - Run all security checks"
@echo " make quality - Run all code quality checks"
@echo " make analysis - Run all analysis tools"
@echo " make check-all - Run all checks (lint, security, quality, analysis)"
@echo " make test - Run tests with Python $(DEFAULT_PYTHON)"
@echo " make test-all - Run tests with all Python versions ($(PYTHON_VERSIONS))"
@echo " make test-<version> - Run tests with specific Python version (e.g., make test-3.10)"
@echo " make local-test - Run tests locally"
@echo " make quality - Run all quality checks"
@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 " make high-load-stress-test - Run high-load stress test"
@echo ""
@echo "Documentation:"
@echo " make serve-docs - Serve documentation"
@echo " make lint-docs - Run markdownlint on documentation"
@echo " make fix-docs - Auto-fix markdownlint issues"
@echo " make prune - Prune docker resources"
@echo " make lint-docs - Lint markdown files"
@echo ""
@echo "Cleanup:"
@echo " make stop - Stop all containers"
@echo " make clean - Clean cache files"
@echo " make help - Show this help message"
@echo " make show-python-versions - Show supported Python versions"
@echo " make prune - Prune docker resources"
@echo ""
@echo "See docs/deployment.md and docs/usage.md for detailed guides."
# Python versions list
.PHONY: show-python-versions
+67 -8
View File
@@ -11,9 +11,9 @@ services:
POSTGRES_PASSWORD: roboco
POSTGRES_DB: roboco
ports:
- "5432:5432"
- "15432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
interval: 10s
@@ -21,23 +21,82 @@ services:
retries: 5
# ==========================================================================
# Redis - Cache, Sessions, Message Queue
# Redis - Cache, Sessions, Event Bus
# ==========================================================================
redis:
image: redis:7-alpine
image: redis:8-alpine
container_name: roboco-redis
restart: unless-stopped
command: redis-server --appendonly yes
ports:
- "6379:6379"
- "16379:6379"
volumes:
- redis_data:/data
- ${ROBOCO_DATA_DIR:-./data}/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ==========================================================================
# Agent Image Builder (pre-builds the agent image at compose time)
# ==========================================================================
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:
postgres_data:
redis_data:
# 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")
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")
workers: int = Field(default=1, ge=1)
@@ -49,6 +53,18 @@ class Settings(BaseSettings):
)
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
# ==========================================================================
+64 -36
View File
@@ -91,10 +91,10 @@ class AgentTable(Base):
# Timestamps
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(
DateTime, onupdate=datetime.now(UTC), nullable=True
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
# Relationships
@@ -154,15 +154,23 @@ class TaskTable(Base):
# Timestamps
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(
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)
plan: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -252,14 +260,16 @@ class ChannelTable(Base):
# Statistics
message_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
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(
DateTime, onupdate=datetime.now(UTC), nullable=True
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
# Relationships
@@ -311,14 +321,16 @@ class GroupTable(Base):
# Statistics
total_sessions: 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
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(
DateTime, onupdate=datetime.now(UTC), nullable=True
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
# Relationships
@@ -372,12 +384,14 @@ class SessionTable(Base):
# Timestamps
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(
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
message_count: Mapped[int] = mapped_column(Integer, default=0)
@@ -385,7 +399,7 @@ class SessionTable(Base):
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
# Relationships
@@ -464,7 +478,7 @@ class MessageTable(Base):
# Metadata
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
@@ -472,12 +486,14 @@ class MessageTable(Base):
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
# 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)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
# Relationships
@@ -545,9 +561,11 @@ class NotificationTable(Base):
# Timing
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_by: Mapped[list[PyUUID]] = mapped_column(
@@ -555,11 +573,13 @@ class NotificationTable(Base):
)
# 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
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now(UTC), nullable=False
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
# Relationships
@@ -592,21 +612,25 @@ class JournalTable(Base):
# Metadata
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
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)
entries_by_type: Mapped[dict[str, int]] = mapped_column(JSON, default=dict)
# Timestamps
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(
DateTime, onupdate=datetime.now(UTC), nullable=True
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
# Relationships
@@ -651,7 +675,7 @@ class JournalEntryTable(Base):
# Metadata
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)
@@ -663,10 +687,10 @@ class JournalEntryTable(Base):
# Timestamps
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(
DateTime, onupdate=datetime.now(UTC), nullable=True
DateTime(timezone=True), onupdate=datetime.now(UTC), nullable=True
)
# Relationships
@@ -750,13 +774,17 @@ class HandoffTable(Base):
# Timestamps
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(
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_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(
error_code: str,
message: str,
@@ -67,7 +62,7 @@ async def _post_journal_entry(
"""Post to a journal endpoint. Returns (data, error)."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{_get_api_url()}/journals/me/{endpoint}",
f"{settings.internal_api_url}/journals/me/{endpoint}",
json=payload,
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:
payload = {"query": query, "top_k": min(top_k, 20)}
resp = await client.post(
f"{_get_api_url()}/journals/me/search",
f"{settings.internal_api_url}/journals/me/search",
json=payload,
headers={"X-Agent-Id": agent_id},
)
@@ -253,11 +248,11 @@ async def _handle_stats(agent_id: str) -> dict[str, Any]:
"""Handle journal stats retrieval."""
async with httpx.AsyncClient() as client:
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},
)
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},
)
@@ -302,7 +297,7 @@ async def _handle_recent(
params["task_id"] = task_id
resp = await client.get(
f"{_get_api_url()}/journals/me/entries",
f"{settings.internal_api_url}/journals/me/entries",
params=params,
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", []))
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
def _format_error_response(
error_code: str,
message: str,
@@ -122,13 +117,15 @@ async def _get_or_create_session(
channel_id: str,
) -> str | dict[str, Any]:
"""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:
return str(session_resp.json()["id"])
create_resp = await client.post(
f"{_get_api_url()}/sessions",
f"{settings.internal_api_url}/sessions",
json={"channel_id": channel_id},
)
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:
channels_resp = await client.get(
f"{_get_api_url()}/channels",
f"{settings.internal_api_url}/channels",
params={"slug": channel_slug},
)
@@ -200,7 +197,7 @@ async def _handle_channel_history(
channel_id = channels[0]["id"]
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},
)
@@ -230,7 +227,7 @@ async def _handle_message_send(
async with httpx.AsyncClient() as client:
channels_resp = await client.get(
f"{_get_api_url()}/channels",
f"{settings.internal_api_url}/channels",
params={"slug": data.channel_slug},
)
@@ -258,7 +255,7 @@ async def _handle_message_send(
}
send_resp = await client.post(
f"{_get_api_url()}/messages",
f"{settings.internal_api_url}/messages",
json=message_data,
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]:
"""Handle message retrieval."""
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:
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}"
def _get_api_url() -> str:
"""Get the RoboCo API base URL."""
return f"http://{settings.host}:{settings.port}/api/v1"
def _format_error_response(
error_code: str,
message: str,
@@ -100,7 +95,7 @@ async def _handle_list(
}
resp = await client.get(
f"{_get_api_url()}/notifications",
f"{settings.internal_api_url}/notifications",
params=params,
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."""
async with httpx.AsyncClient() as client:
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},
)
@@ -168,7 +163,7 @@ async def _handle_ack(agent_id: str, notification_id: str) -> dict[str, Any]:
"""Handle acknowledging a notification."""
async with httpx.AsyncClient() as client:
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},
)
@@ -249,7 +244,7 @@ async def _handle_send(agent_id: str, data: SendNotificationInput) -> dict[str,
}
resp = await client.post(
f"{_get_api_url()}/notifications",
f"{settings.internal_api_url}/notifications",
json=payload,
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
# =============================================================================
# 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(
task: dict[str, Any],
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:
# Get paused tasks for this agent
paused_resp = await client.get(
f"{_get_api_url()}/tasks",
f"{settings.internal_api_url}/tasks",
params={"assigned_to": agent_id, "status": "paused"},
)
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)
assigned_resp = await client.get(
f"{_get_api_url()}/tasks",
f"{settings.internal_api_url}/tasks",
params={"assigned_to": agent_id},
)
assigned_data = (
@@ -187,7 +177,7 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]:
if team:
params["team"] = team
available_resp = await client.get(
f"{_get_api_url()}/tasks",
f"{settings.internal_api_url}/tasks",
params=params,
)
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]:
"""Handle getting task details."""
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:
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:
"""Fetch project context if available."""
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:
result: dict[str, Any] = resp.json()
return result
@@ -296,7 +286,7 @@ async def _handle_task_claim(task_id: str, agent_id: str) -> dict[str, Any]:
"""Handle task claiming."""
async with httpx.AsyncClient() as client:
active_resp = await client.get(
f"{_get_api_url()}/tasks",
f"{settings.internal_api_url}/tasks",
params={"assigned_to": agent_id},
)
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):
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:
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
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},
)
if claim_resp.status_code != status.HTTP_200_OK:
@@ -392,7 +382,7 @@ async def _handle_task_plan(
) -> dict[str, Any]:
"""Handle task planning."""
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:
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)
update_resp = await client.patch(
f"{_get_api_url()}/tasks/{task_id}",
f"{settings.internal_api_url}/tasks/{task_id}",
json={"plan": plan_data},
)
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]:
"""Handle task start."""
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:
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
# 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:
return _format_error_response(
@@ -509,7 +501,7 @@ async def _handle_task_progress(
) -> dict[str, Any]:
"""Handle task progress update."""
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:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -528,7 +520,7 @@ async def _handle_task_progress(
# Add progress update
progress_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/progress",
f"{settings.internal_api_url}/tasks/{task_id}/progress",
json={
"agent_id": agent_id,
"message": message,
@@ -566,7 +558,7 @@ async def _handle_task_block(
)
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:
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_resp = await client.post(
f"{_get_api_url()}/tasks/{task_id}/block",
f"{settings.internal_api_url}/tasks/{task_id}/block",
json={
"reason": reason,
"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]:
"""Handle task unblocking."""
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:
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",
)
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:
return _format_error_response("UNBLOCK_FAILED", "Failed to unblock task")
@@ -654,7 +648,7 @@ async def _handle_task_pause(
) -> dict[str, Any]:
"""Handle task pausing."""
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:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -673,7 +667,7 @@ async def _handle_task_pause(
# Add checkpoint
await client.post(
f"{_get_api_url()}/tasks/{task_id}/checkpoint",
f"{settings.internal_api_url}/tasks/{task_id}/checkpoint",
json={
"agent_id": agent_id,
"state_summary": checkpoint_summary,
@@ -683,7 +677,9 @@ async def _handle_task_pause(
)
# 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:
return _format_error_response("PAUSE_FAILED", "Failed to pause task")
@@ -705,7 +701,7 @@ async def _handle_task_submit_verification(
) -> dict[str, Any]:
"""Handle task verification submission."""
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:
return _format_error_response("NOT_FOUND", f"Task {task_id} not found")
@@ -731,7 +727,9 @@ async def _handle_task_submit_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:
return _format_error_response(
@@ -768,7 +766,7 @@ async def _handle_task_submit_qa(
)
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:
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
await client.patch(
f"{_get_api_url()}/tasks/{task_id}",
f"{settings.internal_api_url}/tasks/{task_id}",
json={
"dev_notes": dev_notes,
"documenter_handoff": handoff_summary,
@@ -795,7 +793,9 @@ async def _handle_task_submit_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:
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:
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:
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(
f"{_get_api_url()}/tasks/{task_id}/pass-qa",
f"{settings.internal_api_url}/tasks/{task_id}/pass-qa",
json={"notes": qa_notes},
)
@@ -882,7 +882,7 @@ async def _handle_task_qa_fail(
)
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:
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)
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},
)
@@ -918,7 +918,7 @@ async def _handle_task_qa_fail(
async def _handle_task_complete(task_id: str) -> dict[str, Any]:
"""Handle task completion."""
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:
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",
)
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:
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
# =============================================================================
@@ -1262,6 +1290,20 @@ def create_task_mcp_server(agent_id: str) -> FastMCP:
"""
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
+2 -3
View File
@@ -4,7 +4,6 @@ Runtime Models
Domain types for the agent orchestrator system.
"""
import asyncio
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
@@ -38,12 +37,12 @@ class OrchestratorAgentConfig:
@dataclass
class AgentInstance:
"""A running Claude Code agent instance."""
"""A running Claude Code agent instance (Docker container)."""
id: UUID = field(default_factory=uuid4)
agent_id: str = ""
state: OrchestratorAgentState = OrchestratorAgentState.OFFLINE
process: asyncio.subprocess.Process | None = None
container_id: str | None = None # Docker container ID
config: OrchestratorAgentConfig | None = None
started_at: datetime | None = None
last_activity: datetime | None = None
+220 -69
View File
@@ -1,7 +1,7 @@
"""
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.
"""
@@ -31,6 +31,19 @@ logger = structlog.get_logger()
AgentState = OrchestratorAgentState
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
@@ -39,11 +52,11 @@ AgentConfig = OrchestratorAgentConfig
class AgentOrchestrator:
"""
Manages Claude Code instances for all agents.
Manages Claude Code containers for all agents.
Responsibilities:
- Spawn agents with correct blueprints
- Monitor health (heartbeat, errors)
- Spawn agents as Docker containers
- Monitor health via docker inspect
- Handle waiting states and respawning
- Provide status API
- Cost-efficient on-demand spawning
@@ -53,9 +66,11 @@ class AgentOrchestrator:
self,
blueprints_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.mcp_config_dir = mcp_config_dir or Path(".mcp")
self.project_root = project_root or Path.cwd()
self._instances: dict[str, AgentInstance] = {}
self._waiting_records: dict[str, WaitingRecord] = {}
@@ -70,6 +85,10 @@ class AgentOrchestrator:
async def start(self) -> None:
"""Start the orchestrator."""
self._running = True
# Ensure agent image is built
await self._ensure_agent_image()
self._health_task = asyncio.create_task(self._health_loop())
logger.info("Orchestrator started")
@@ -88,6 +107,42 @@ class AgentOrchestrator:
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
# =========================================================================
@@ -100,7 +155,7 @@ class AgentOrchestrator:
model: str | None = None,
) -> AgentInstance:
"""
Spawn a Claude Code instance for an agent.
Spawn a Claude Code container for an agent.
Args:
agent_id: Agent identifier (e.g., "be-dev-1")
@@ -154,10 +209,10 @@ class AgentOrchestrator:
self._instances[agent_id] = instance
# Spawn the process
# Spawn the container
try:
process = await self._spawn_process(config, initial_prompt)
instance.process = process
container_id = await self._spawn_container(config, initial_prompt)
instance.container_id = container_id
instance.state = AgentState.ACTIVE
instance.started_at = datetime.now(UTC)
instance.last_activity = datetime.now(UTC)
@@ -165,6 +220,7 @@ class AgentOrchestrator:
logger.info(
"Agent spawned",
agent_id=agent_id,
container_id=container_id[:12],
model=model,
task_id=task_id,
)
@@ -181,90 +237,149 @@ class AgentOrchestrator:
)
raise
async def _spawn_process(
async def _spawn_container(
self,
config: AgentConfig,
initial_prompt: str | None = None,
) -> asyncio.subprocess.Process:
"""Spawn the actual Claude Code process."""
) -> str:
"""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 = [
"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_MAP.get(config.model, config.model),
"--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",
"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:
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(
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=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:
"""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 = {
"mcpServers": {
"roboco-task": {
"command": "python",
"args": [
"-m",
"roboco.mcp.task_server",
agent_id,
],
"command": "uv",
"args": ["run", "python", "-m", "roboco.mcp.task_server", agent_id],
},
"roboco-message": {
"command": "python",
"command": "uv",
"args": [
"-m",
"roboco.mcp.message_server",
agent_id,
"run", "python", "-m", "roboco.mcp.message_server", agent_id
],
},
"roboco-notify": {
"command": "python",
"command": "uv",
"args": [
"-m",
"roboco.mcp.notify_server",
agent_id,
"run", "python", "-m", "roboco.mcp.notify_server", agent_id
],
},
"roboco-journal": {
"command": "python",
"command": "uv",
"args": [
"-m",
"roboco.mcp.journal_server",
agent_id,
"run", "python", "-m", "roboco.mcp.journal_server", agent_id
],
},
}
}
# Write to temp file
config_path = Path(tempfile.gettempdir()) / f"roboco-mcp-{agent_id}.json"
# Write to shared config directory (mounted in both orchestrator and agents)
# 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))
return config_path
def _get_blueprint_path(self, agent_id: str) -> Path:
"""Get blueprint path for an agent."""
# Map agent_id to blueprint
role = self._get_agent_role(agent_id)
team = self._get_agent_team(agent_id)
@@ -278,9 +393,25 @@ class AgentOrchestrator:
cell_dir = "board"
blueprint_file = f"{role.replace('_', '-')}.md"
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:
"""Get role from agent_id."""
role_map = {
@@ -320,33 +451,39 @@ class AgentOrchestrator:
# =========================================================================
async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
"""Stop an agent."""
"""Stop an agent container."""
async with self._lock:
if agent_id not in self._instances:
return
instance = self._instances[agent_id]
if instance.process and instance.process.returncode is None:
if instance.container_id:
instance.state = AgentState.STOPPING
container_name = f"roboco-agent-{agent_id}"
if graceful:
# Send interrupt
instance.process.terminate()
try:
await asyncio.wait_for(
instance.process.wait(),
timeout=10.0,
# Graceful stop with timeout
proc = await asyncio.create_subprocess_exec(
"docker", "stop", "-t", "10", container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
except TimeoutError:
instance.process.kill()
await instance.process.wait()
await proc.wait()
else:
instance.process.kill()
await instance.process.wait()
# Force kill
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.process = None
instance.container_id = None
logger.info("Agent stopped", agent_id=agent_id)
@@ -501,18 +638,30 @@ Start by:
if instance.state not in (AgentState.ACTIVE, AgentState.WAITING_SHORT):
continue
if instance.process is None:
if instance.container_id is None:
continue
# Check if process died
if instance.process.returncode is not None:
# Check if container is still running
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(
"Agent process died",
"Agent container stopped",
agent_id=agent_id,
returncode=instance.process.returncode,
container_id=cid,
)
instance.state = AgentState.OFFLINE
instance.error_count += 1
instance.container_id = None
# Auto-restart if not too many errors
max_retries = 3
@@ -556,10 +705,12 @@ Start by:
by_state[state.value] = count
for agent_id, instance in self._instances.items():
cid = instance.container_id[:12] if instance.container_id else None
agents.append(
{
"agent_id": agent_id,
"state": instance.state.value,
"container_id": cid,
"task_id": instance.current_task_id,
"error_count": instance.error_count,
"started_at": instance.started_at.isoformat()
+10 -10
View File
@@ -36,25 +36,25 @@ DEFAULT_CHANNELS = [
"slug": "dev-all",
"name": "All Developers",
"description": "Cross-cell developer discussion",
"channel_type": "role",
"channel_type": "cross_cell",
},
{
"slug": "qa-all",
"name": "All QA",
"description": "Cross-cell QA discussion",
"channel_type": "role",
"channel_type": "cross_cell",
},
{
"slug": "pm-all",
"name": "All PMs",
"description": "Cross-cell PM coordination",
"channel_type": "role",
"channel_type": "cross_cell",
},
{
"slug": "doc-all",
"name": "All Documenters",
"description": "Cross-cell documentation discussion",
"channel_type": "role",
"channel_type": "cross_cell",
},
# Management channels
{
@@ -74,13 +74,13 @@ DEFAULT_CHANNELS = [
"slug": "announcements",
"name": "Announcements",
"description": "Company-wide announcements (read-only for most)",
"channel_type": "broadcast",
"channel_type": "special",
},
{
"slug": "all-hands",
"name": "All Hands",
"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",
"name": "UX/UI Developer",
"role": "developer",
"team": "uxui",
"team": "ux_ui",
},
{"agent_id": "ux-qa", "name": "UX/UI QA", "role": "qa", "team": "uxui"},
{"agent_id": "ux-pm", "name": "UX/UI PM", "role": "cell_pm", "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": "ux_ui"},
{
"agent_id": "ux-doc",
"name": "UX/UI Documenter",
"role": "documenter",
"team": "uxui",
"team": "ux_ui",
},
# Board / Management
{"agent_id": "main-pm", "name": "Main PM", "role": "main_pm", "team": None},