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