diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..57868c4d --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index fe5e2944..958b203d 100644 --- a/.env.example +++ b/.env.example @@ -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"] diff --git a/Makefile b/Makefile index 4be5c76c..52777e06 100644 --- a/Makefile +++ b/Makefile @@ -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 " 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 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- - Run tests with specific Python version (e.g., make test-3.10)" - @echo " make local-test - Run tests locally" - @echo " make stress-test - Run stress test" - @echo " make high-load-stress-test - Run high-load stress test" - @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 "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 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 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 "" + @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 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 diff --git a/docker-compose.yml b/docker-compose.yml index 2729f665..a7a7b54a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 -volumes: - postgres_data: - redis_data: + # ========================================================================== + # 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: + # 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 diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile new file mode 100644 index 00000000..6ccfd673 --- /dev/null +++ b/docker/agent.Dockerfile @@ -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"] diff --git a/docker/orchestrator.Dockerfile b/docker/orchestrator.Dockerfile new file mode 100644 index 00000000..ff3cae9d --- /dev/null +++ b/docker/orchestrator.Dockerfile @@ -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"] diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..7647305f --- /dev/null +++ b/docs/deployment.md @@ -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 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 +``` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..d77f9793 --- /dev/null +++ b/docs/usage.md @@ -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" +``` diff --git a/roboco/config.py b/roboco/config.py index 0db76f98..fe57d33c 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -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 # ========================================================================== diff --git a/roboco/db/tables.py b/roboco/db/tables.py index e9d39700..465e7a5f 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -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) diff --git a/roboco/mcp/journal_server.py b/roboco/mcp/journal_server.py index be9bafb0..4b6d3031 100644 --- a/roboco/mcp/journal_server.py +++ b/roboco/mcp/journal_server.py @@ -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}, ) diff --git a/roboco/mcp/message_server.py b/roboco/mcp/message_server.py index 1b2d542c..e0fa1aeb 100644 --- a/roboco/mcp/message_server.py +++ b/roboco/mcp/message_server.py @@ -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( diff --git a/roboco/mcp/notify_server.py b/roboco/mcp/notify_server.py index 05cb92c9..1204483a 100644 --- a/roboco/mcp/notify_server.py +++ b/roboco/mcp/notify_server.py @@ -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}, ) diff --git a/roboco/mcp/task_server.py b/roboco/mcp/task_server.py index e3583d72..bd07365f 100644 --- a/roboco/mcp/task_server.py +++ b/roboco/mcp/task_server.py @@ -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 diff --git a/roboco/models/runtime.py b/roboco/models/runtime.py index ceaa308c..0d8cb214 100644 --- a/roboco/models/runtime.py +++ b/roboco/models/runtime.py @@ -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 diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 45686260..db0250e6 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -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, - ) - except TimeoutError: - instance.process.kill() - await instance.process.wait() + # Graceful stop with timeout + proc = await asyncio.create_subprocess_exec( + "docker", "stop", "-t", "10", container_name, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + 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() diff --git a/roboco/seeds/initial_data.py b/roboco/seeds/initial_data.py index fc87ccef..fcaed669 100644 --- a/roboco/seeds/initial_data.py +++ b/roboco/seeds/initial_data.py @@ -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},