Complete documentation for future sessions
- CLAUDE.md for AI agents to understand the codebase - GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth) - DEVELOPMENT-WORKFLOW.md explains complete dev process - ROADMAP.md, NEXT-SESSION.md for planning - QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use - 40+ detailed docs in /docs folder - Backend as submodule from Gitea Everything documented for autonomous operation. Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
452
docs/05-agents/ciclo-vida.md
Normal file
452
docs/05-agents/ciclo-vida.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Ciclo de Vida de los Agentes
|
||||
|
||||
## Estados del Agente
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Initializing │
|
||||
└──────┬───────┘
|
||||
│
|
||||
▼
|
||||
┌──────┐ ┌──────┐
|
||||
│ Idle │◄───►│ Busy │
|
||||
└───┬──┘ └──┬───┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────┐ ┌───────┐
|
||||
│ Error │ │Offline│
|
||||
└───────┘ └───────┘
|
||||
```
|
||||
|
||||
## Inicialización
|
||||
|
||||
### 1. Creación del Pod
|
||||
|
||||
```typescript
|
||||
// Backend crea el pod
|
||||
const agentManager = new AgentManager()
|
||||
const agent = await agentManager.createAgent(['javascript', 'react'])
|
||||
|
||||
// Resultado
|
||||
{
|
||||
id: 'agent-abc123',
|
||||
podName: 'claude-agent-abc123',
|
||||
namespace: 'agents',
|
||||
status: 'initializing'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Arranque del Contenedor
|
||||
|
||||
```bash
|
||||
# En el pod (entrypoint.sh)
|
||||
echo "🤖 Starting agent: $AGENT_ID"
|
||||
|
||||
# 1. Setup SSH
|
||||
echo "$GIT_SSH_KEY" > /root/.ssh/id_ed25519
|
||||
chmod 600 /root/.ssh/id_ed25519
|
||||
|
||||
# 2. Configure Claude Code MCP
|
||||
cat > /root/.claude-code/config.json <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"aiworker": {
|
||||
"url": "$MCP_SERVER_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 3. Send initial heartbeat
|
||||
curl -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: $AGENT_ID" \
|
||||
-d '{"status":"idle"}'
|
||||
|
||||
# 4. Start work loop
|
||||
exec /usr/local/bin/agent-loop.sh
|
||||
```
|
||||
|
||||
### 3. Registro en el Sistema
|
||||
|
||||
```typescript
|
||||
// Backend detecta el heartbeat y actualiza
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'idle',
|
||||
lastHeartbeat: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
logger.info(`Agent ${agentId} is now active`)
|
||||
```
|
||||
|
||||
## Asignación de Tarea
|
||||
|
||||
### 1. Agent Polling
|
||||
|
||||
```bash
|
||||
# agent-loop.sh
|
||||
while true; do
|
||||
echo "📋 Checking for tasks..."
|
||||
|
||||
TASK=$(curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"get_next_task\",
|
||||
\"arguments\": {\"agentId\": \"$AGENT_ID\"}
|
||||
}")
|
||||
|
||||
TASK_ID=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.id // empty')
|
||||
|
||||
if [ -n "$TASK_ID" ]; then
|
||||
echo "🎯 Got task: $TASK_ID"
|
||||
process_task "$TASK_ID"
|
||||
else
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### 2. Backend Asigna Tarea
|
||||
|
||||
```typescript
|
||||
// services/mcp/handlers.ts - getNextTask()
|
||||
async function getNextTask(args: { agentId: string }) {
|
||||
// 1. Buscar siguiente tarea en backlog
|
||||
const task = await db.query.tasks.findFirst({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
orderBy: [desc(tasks.priority), asc(tasks.createdAt)],
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ message: 'No tasks' }) }] }
|
||||
}
|
||||
|
||||
// 2. Asignar al agente
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'in_progress',
|
||||
assignedAgentId: args.agentId,
|
||||
assignedAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, task.id))
|
||||
|
||||
// 3. Actualizar agente
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'busy',
|
||||
currentTaskId: task.id,
|
||||
})
|
||||
.where(eq(agents.id, args.agentId))
|
||||
|
||||
// 4. Retornar tarea
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ task }),
|
||||
}],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Trabajo en Tarea
|
||||
|
||||
### Fase 1: Setup
|
||||
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone "$PROJECT_REPO" "/workspace/task-$TASK_ID"
|
||||
cd "/workspace/task-$TASK_ID"
|
||||
|
||||
# Create branch (via MCP)
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{\"name\": \"create_branch\", \"arguments\": {\"taskId\": \"$TASK_ID\"}}"
|
||||
|
||||
# Checkout branch
|
||||
git fetch origin
|
||||
git checkout "$BRANCH_NAME"
|
||||
```
|
||||
|
||||
### Fase 2: Implementación
|
||||
|
||||
```bash
|
||||
# Start Claude Code session
|
||||
claude-code chat --message "
|
||||
I need you to work on this task:
|
||||
|
||||
Title: $TASK_TITLE
|
||||
Description: $TASK_DESC
|
||||
|
||||
Instructions:
|
||||
1. Analyze the codebase
|
||||
2. Implement the changes
|
||||
3. Write tests
|
||||
4. Commit with clear messages
|
||||
5. Use MCP tools when done
|
||||
|
||||
Start working now.
|
||||
"
|
||||
```
|
||||
|
||||
### Fase 3: Preguntas (opcional)
|
||||
|
||||
```typescript
|
||||
// Si el agente necesita info
|
||||
await mcp.callTool('ask_user_question', {
|
||||
taskId,
|
||||
question: 'Should I add TypeScript types?',
|
||||
context: 'The codebase is in JavaScript...',
|
||||
})
|
||||
|
||||
// Cambiar estado a needs_input
|
||||
await mcp.callTool('update_task_status', {
|
||||
taskId,
|
||||
status: 'needs_input',
|
||||
})
|
||||
|
||||
// Hacer polling cada 5s hasta respuesta
|
||||
let response
|
||||
while (!response) {
|
||||
await sleep(5000)
|
||||
const check = await mcp.callTool('check_question_response', { taskId })
|
||||
if (check.hasResponse) {
|
||||
response = check.response
|
||||
}
|
||||
}
|
||||
|
||||
// Continuar con la respuesta
|
||||
await mcp.callTool('update_task_status', {
|
||||
taskId,
|
||||
status: 'in_progress',
|
||||
})
|
||||
```
|
||||
|
||||
### Fase 4: Finalización
|
||||
|
||||
```bash
|
||||
# Create PR
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"create_pull_request\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"title\": \"$TASK_TITLE\",
|
||||
\"description\": \"Implemented feature X...\"
|
||||
}
|
||||
}"
|
||||
|
||||
# Deploy preview
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"trigger_preview_deploy\",
|
||||
\"arguments\": {\"taskId\": \"$TASK_ID\"}
|
||||
}"
|
||||
|
||||
# Update status
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"update_task_status\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"status\": \"ready_to_test\"
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Liberación del Agente
|
||||
|
||||
```typescript
|
||||
// Cuando tarea completa (ready_to_test o completed)
|
||||
await db.update(agents)
|
||||
.set({
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
tasksCompleted: sql`tasks_completed + 1`,
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
logger.info(`Agent ${agentId} completed task ${taskId}, now idle`)
|
||||
```
|
||||
|
||||
## Manejo de Errores
|
||||
|
||||
### Timeout de Tarea
|
||||
|
||||
```bash
|
||||
# agent-loop.sh con timeout
|
||||
timeout 7200 claude-code chat --message "$TASK_PROMPT" || {
|
||||
STATUS=$?
|
||||
if [ $STATUS -eq 124 ]; then
|
||||
echo "⏰ Task timeout after 2 hours"
|
||||
|
||||
# Notify backend
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"update_task_status\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"status\": \"needs_input\",
|
||||
\"metadata\": {\"reason\": \"timeout\"}
|
||||
}
|
||||
}"
|
||||
|
||||
# Log error
|
||||
curl -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-d "{
|
||||
\"name\": \"log_activity\",
|
||||
\"arguments\": {
|
||||
\"agentId\": \"$AGENT_ID\",
|
||||
\"level\": \"error\",
|
||||
\"message\": \"Task timeout: $TASK_ID\"
|
||||
}
|
||||
}"
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
### Crash del Agente
|
||||
|
||||
```typescript
|
||||
// Backend detecta agente sin heartbeat
|
||||
async function checkStaleAgents() {
|
||||
const staleThreshold = new Date(Date.now() - 5 * 60 * 1000) // 5 min
|
||||
|
||||
const staleAgents = await db.query.agents.findMany({
|
||||
where: lt(agents.lastHeartbeat, staleThreshold),
|
||||
})
|
||||
|
||||
for (const agent of staleAgents) {
|
||||
logger.warn(`Agent ${agent.id} is stale`)
|
||||
|
||||
// Mark current task as needs attention
|
||||
if (agent.currentTaskId) {
|
||||
await db.update(tasks)
|
||||
.set({
|
||||
state: 'backlog',
|
||||
assignedAgentId: null,
|
||||
})
|
||||
.where(eq(tasks.id, agent.currentTaskId))
|
||||
}
|
||||
|
||||
// Delete agent pod
|
||||
await k8sClient.deletePod(agent.k8sNamespace, agent.podName)
|
||||
|
||||
// Remove from DB
|
||||
await db.delete(agents).where(eq(agents.id, agent.id))
|
||||
|
||||
// Create replacement
|
||||
await agentManager.createAgent()
|
||||
}
|
||||
}
|
||||
|
||||
// Run every minute
|
||||
setInterval(checkStaleAgents, 60000)
|
||||
```
|
||||
|
||||
## Terminación Graciosa
|
||||
|
||||
```bash
|
||||
# agent-entrypoint.sh
|
||||
cleanup() {
|
||||
echo "🛑 Shutting down agent..."
|
||||
|
||||
# Send offline status
|
||||
curl -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-d '{"status":"offline"}' 2>/dev/null || true
|
||||
|
||||
# Kill background jobs
|
||||
kill $HEARTBEAT_PID 2>/dev/null || true
|
||||
|
||||
echo "👋 Goodbye"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# Wait for signals
|
||||
wait
|
||||
```
|
||||
|
||||
## Auto-Scaling
|
||||
|
||||
```typescript
|
||||
// Auto-scaler que corre cada 30s
|
||||
async function autoScale() {
|
||||
// Get metrics
|
||||
const pendingTasks = await db.query.tasks.findMany({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
})
|
||||
|
||||
const idleAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'idle'),
|
||||
})
|
||||
|
||||
const busyAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'busy'),
|
||||
})
|
||||
|
||||
const totalAgents = idleAgents.length + busyAgents.length
|
||||
|
||||
// Decision logic
|
||||
let targetAgents = totalAgents
|
||||
|
||||
// Scale up if:
|
||||
// - More than 3 pending tasks
|
||||
// - No idle agents
|
||||
if (pendingTasks.length > 3 && idleAgents.length === 0) {
|
||||
targetAgents = Math.min(totalAgents + 2, 10) // Max 10
|
||||
}
|
||||
|
||||
// Scale down if:
|
||||
// - No pending tasks
|
||||
// - More than 2 idle agents
|
||||
if (pendingTasks.length === 0 && idleAgents.length > 2) {
|
||||
targetAgents = Math.max(totalAgents - 1, 2) // Min 2
|
||||
}
|
||||
|
||||
if (targetAgents !== totalAgents) {
|
||||
logger.info(`Auto-scaling: ${totalAgents} → ${targetAgents}`)
|
||||
await agentManager.scaleAgents(targetAgents)
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(autoScale, 30000)
|
||||
```
|
||||
|
||||
## Métricas del Ciclo de Vida
|
||||
|
||||
```typescript
|
||||
// Endpoint para métricas de agentes
|
||||
router.get('/agents/metrics', async (req, res) => {
|
||||
const agents = await db.query.agents.findMany()
|
||||
|
||||
const metrics = {
|
||||
total: agents.length,
|
||||
byStatus: {
|
||||
idle: agents.filter((a) => a.status === 'idle').length,
|
||||
busy: agents.filter((a) => a.status === 'busy').length,
|
||||
error: agents.filter((a) => a.status === 'error').length,
|
||||
offline: agents.filter((a) => a.status === 'offline').length,
|
||||
},
|
||||
totalTasksCompleted: agents.reduce((sum, a) => sum + a.tasksCompleted, 0),
|
||||
avgTasksPerAgent:
|
||||
agents.reduce((sum, a) => sum + a.tasksCompleted, 0) / agents.length || 0,
|
||||
totalRuntime: agents.reduce((sum, a) => sum + a.totalRuntimeMinutes, 0),
|
||||
}
|
||||
|
||||
res.json(metrics)
|
||||
})
|
||||
```
|
||||
|
||||
## Dashboard Visualization
|
||||
|
||||
En el frontend, mostrar:
|
||||
- **Estado actual** de cada agente (idle/busy/error)
|
||||
- **Tarea actual** si está busy
|
||||
- **Historial** de tareas completadas
|
||||
- **Métricas** (tareas/hora, uptime, etc.)
|
||||
- **Botones** para restart/delete agente
|
||||
- **Logs en tiempo real** de cada agente
|
||||
499
docs/05-agents/claude-code-pods.md
Normal file
499
docs/05-agents/claude-code-pods.md
Normal file
@@ -0,0 +1,499 @@
|
||||
# Claude Code Agents - Pods en Kubernetes
|
||||
|
||||
## Dockerfile del Agente
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
openssh-client \
|
||||
curl \
|
||||
bash \
|
||||
vim
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:$PATH"
|
||||
|
||||
# Install Claude Code CLI
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create workspace
|
||||
WORKDIR /workspace
|
||||
|
||||
# Copy agent scripts
|
||||
COPY scripts/agent-entrypoint.sh /usr/local/bin/
|
||||
COPY scripts/agent-loop.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/agent-*.sh
|
||||
|
||||
# Git config
|
||||
RUN git config --global user.name "AiWorker Agent" && \
|
||||
git config --global user.email "agent@aiworker.dev" && \
|
||||
git config --global init.defaultBranch main
|
||||
|
||||
# Setup SSH
|
||||
RUN mkdir -p /root/.ssh && \
|
||||
ssh-keyscan -H git.aiworker.dev >> /root/.ssh/known_hosts
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]
|
||||
```
|
||||
|
||||
## Agent Entrypoint Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/agent-entrypoint.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🤖 Starting AiWorker Agent..."
|
||||
echo "Agent ID: $AGENT_ID"
|
||||
|
||||
# Setup SSH key
|
||||
if [ -n "$GIT_SSH_KEY" ]; then
|
||||
echo "$GIT_SSH_KEY" > /root/.ssh/id_ed25519
|
||||
chmod 600 /root/.ssh/id_ed25519
|
||||
fi
|
||||
|
||||
# Configure Claude Code with MCP Server
|
||||
cat > /root/.claude-code/config.json <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"aiworker": {
|
||||
"command": "curl",
|
||||
"args": [
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", "X-Agent-ID: $AGENT_ID",
|
||||
"$MCP_SERVER_URL/rpc"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Send heartbeat
|
||||
send_heartbeat() {
|
||||
curl -s -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"agentId\":\"$AGENT_ID\",\"status\":\"$1\"}" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Start heartbeat loop in background
|
||||
while true; do
|
||||
send_heartbeat "idle"
|
||||
sleep 30
|
||||
done &
|
||||
HEARTBEAT_PID=$!
|
||||
|
||||
# Trap signals for graceful shutdown
|
||||
trap "kill $HEARTBEAT_PID; send_heartbeat 'offline'; exit 0" SIGTERM SIGINT
|
||||
|
||||
# Start agent work loop
|
||||
exec /usr/local/bin/agent-loop.sh
|
||||
```
|
||||
|
||||
## Agent Work Loop
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/agent-loop.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔄 Starting agent work loop..."
|
||||
|
||||
while true; do
|
||||
echo "📋 Checking for tasks..."
|
||||
|
||||
# Get next task via MCP
|
||||
TASK=$(curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"get_next_task\",
|
||||
\"arguments\": {
|
||||
\"agentId\": \"$AGENT_ID\"
|
||||
}
|
||||
}")
|
||||
|
||||
TASK_ID=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.id // empty')
|
||||
|
||||
if [ -z "$TASK_ID" ] || [ "$TASK_ID" = "null" ]; then
|
||||
echo "💤 No tasks available, waiting..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "🎯 Got task: $TASK_ID"
|
||||
|
||||
# Extract task details
|
||||
TASK_TITLE=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.title')
|
||||
TASK_DESC=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.description')
|
||||
PROJECT_REPO=$(echo "$TASK" | jq -r '.content[0].text | fromjson | .task.project.giteaRepoUrl')
|
||||
|
||||
echo "📝 Task: $TASK_TITLE"
|
||||
echo "📦 Repo: $PROJECT_REPO"
|
||||
|
||||
# Log activity
|
||||
curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"log_activity\",
|
||||
\"arguments\": {
|
||||
\"agentId\": \"$AGENT_ID\",
|
||||
\"level\": \"info\",
|
||||
\"message\": \"Starting task: $TASK_TITLE\"
|
||||
}
|
||||
}" > /dev/null
|
||||
|
||||
# Clone repository
|
||||
REPO_DIR="/workspace/task-$TASK_ID"
|
||||
if [ ! -d "$REPO_DIR" ]; then
|
||||
echo "📥 Cloning repository..."
|
||||
git clone "$PROJECT_REPO" "$REPO_DIR"
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# Create branch via MCP
|
||||
echo "🌿 Creating branch..."
|
||||
BRANCH_RESULT=$(curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"create_branch\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\"
|
||||
}
|
||||
}")
|
||||
|
||||
BRANCH_NAME=$(echo "$BRANCH_RESULT" | jq -r '.content[0].text | fromjson | .branchName')
|
||||
echo "🌿 Branch: $BRANCH_NAME"
|
||||
|
||||
# Fetch and checkout
|
||||
git fetch origin
|
||||
git checkout "$BRANCH_NAME" 2>/dev/null || git checkout -b "$BRANCH_NAME"
|
||||
|
||||
# Start Claude Code session
|
||||
echo "🧠 Starting Claude Code session..."
|
||||
|
||||
# Create task prompt
|
||||
TASK_PROMPT="I need you to work on the following task:
|
||||
|
||||
Title: $TASK_TITLE
|
||||
|
||||
Description:
|
||||
$TASK_DESC
|
||||
|
||||
Instructions:
|
||||
1. Analyze the codebase
|
||||
2. Implement the required changes
|
||||
3. Write tests if needed
|
||||
4. Commit your changes with clear messages
|
||||
5. When done, use the MCP tools to:
|
||||
- create_pull_request with a summary
|
||||
- trigger_preview_deploy
|
||||
- update_task_status to 'ready_to_test'
|
||||
|
||||
If you need clarification, use ask_user_question.
|
||||
|
||||
Start working on this task now."
|
||||
|
||||
# Run Claude Code (with timeout of 2 hours)
|
||||
timeout 7200 claude-code chat --message "$TASK_PROMPT" || {
|
||||
STATUS=$?
|
||||
if [ $STATUS -eq 124 ]; then
|
||||
echo "⏰ Task timeout"
|
||||
curl -s -X POST "$MCP_SERVER_URL/tools/call" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"update_task_status\",
|
||||
\"arguments\": {
|
||||
\"taskId\": \"$TASK_ID\",
|
||||
\"status\": \"needs_input\",
|
||||
\"metadata\": {\"reason\": \"timeout\"}
|
||||
}
|
||||
}" > /dev/null
|
||||
else
|
||||
echo "❌ Claude Code exited with status $STATUS"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "✅ Task completed: $TASK_ID"
|
||||
|
||||
# Cleanup
|
||||
cd /workspace
|
||||
rm -rf "$REPO_DIR"
|
||||
|
||||
# Brief pause before next task
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
## Pod Specification
|
||||
|
||||
```yaml
|
||||
# k8s/agents/claude-agent-pod.yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: claude-agent-{{ AGENT_ID }}
|
||||
namespace: agents
|
||||
labels:
|
||||
app: claude-agent
|
||||
agent-id: "{{ AGENT_ID }}"
|
||||
managed-by: aiworker
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
serviceAccountName: claude-agent
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: aiworker/claude-agent:latest
|
||||
imagePullPolicy: Always
|
||||
|
||||
env:
|
||||
- name: AGENT_ID
|
||||
value: "{{ AGENT_ID }}"
|
||||
|
||||
- name: MCP_SERVER_URL
|
||||
value: "http://aiworker-backend.control-plane.svc.cluster.local:3100"
|
||||
|
||||
- name: ANTHROPIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aiworker-secrets
|
||||
key: anthropic-api-key
|
||||
|
||||
- name: GITEA_URL
|
||||
value: "http://gitea.gitea.svc.cluster.local:3000"
|
||||
|
||||
- name: GIT_SSH_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: git-ssh-keys
|
||||
key: private-key
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: "4Gi"
|
||||
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir:
|
||||
sizeLimit: 10Gi
|
||||
```
|
||||
|
||||
## Agent Manager (Backend)
|
||||
|
||||
```typescript
|
||||
// services/kubernetes/agent-manager.ts
|
||||
import { K8sClient } from './client'
|
||||
import { db } from '../../db/client'
|
||||
import { agents } from '../../db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import crypto from 'crypto'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export class AgentManager {
|
||||
private k8sClient: K8sClient
|
||||
|
||||
constructor() {
|
||||
this.k8sClient = new K8sClient()
|
||||
}
|
||||
|
||||
async createAgent(capabilities: string[] = []) {
|
||||
const agentId = crypto.randomUUID()
|
||||
|
||||
// Create agent pod in K8s
|
||||
const { podName, namespace } = await this.k8sClient.createAgentPod(agentId)
|
||||
|
||||
// Insert in database
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
podName,
|
||||
k8sNamespace: namespace,
|
||||
status: 'initializing',
|
||||
capabilities,
|
||||
lastHeartbeat: new Date(),
|
||||
})
|
||||
|
||||
logger.info(`Created agent: ${agentId}`)
|
||||
|
||||
return {
|
||||
id: agentId,
|
||||
podName,
|
||||
namespace,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string) {
|
||||
const agent = await db.query.agents.findFirst({
|
||||
where: eq(agents.id, agentId),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
throw new Error('Agent not found')
|
||||
}
|
||||
|
||||
// Delete pod
|
||||
await this.k8sClient.deletePod(agent.k8sNamespace, agent.podName)
|
||||
|
||||
// Delete from database
|
||||
await db.delete(agents).where(eq(agents.id, agentId))
|
||||
|
||||
logger.info(`Deleted agent: ${agentId}`)
|
||||
}
|
||||
|
||||
async scaleAgents(targetCount: number) {
|
||||
const currentAgents = await db.query.agents.findMany()
|
||||
|
||||
if (currentAgents.length < targetCount) {
|
||||
// Scale up
|
||||
const toCreate = targetCount - currentAgents.length
|
||||
logger.info(`Scaling up: creating ${toCreate} agents`)
|
||||
|
||||
for (let i = 0; i < toCreate; i++) {
|
||||
await this.createAgent()
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)) // Stagger creation
|
||||
}
|
||||
} else if (currentAgents.length > targetCount) {
|
||||
// Scale down
|
||||
const toDelete = currentAgents.length - targetCount
|
||||
logger.info(`Scaling down: deleting ${toDelete} agents`)
|
||||
|
||||
// Delete idle agents first
|
||||
const idleAgents = currentAgents.filter(a => a.status === 'idle').slice(0, toDelete)
|
||||
|
||||
for (const agent of idleAgents) {
|
||||
await this.deleteAgent(agent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async autoScale() {
|
||||
// Get pending tasks
|
||||
const pendingTasks = await db.query.tasks.findMany({
|
||||
where: eq(tasks.state, 'backlog'),
|
||||
})
|
||||
|
||||
// Get available agents
|
||||
const availableAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'idle'),
|
||||
})
|
||||
|
||||
const busyAgents = await db.query.agents.findMany({
|
||||
where: eq(agents.status, 'busy'),
|
||||
})
|
||||
|
||||
const totalAgents = availableAgents.length + busyAgents.length
|
||||
|
||||
// Simple scaling logic
|
||||
const targetAgents = Math.min(
|
||||
Math.max(2, pendingTasks.length, busyAgents.length + 1), // At least 2, max 1 per pending task
|
||||
10 // Max 10 agents
|
||||
)
|
||||
|
||||
if (targetAgents !== totalAgents) {
|
||||
logger.info(`Auto-scaling agents: ${totalAgents} → ${targetAgents}`)
|
||||
await this.scaleAgents(targetAgents)
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupStaleAgents() {
|
||||
const staleThreshold = new Date(Date.now() - 5 * 60 * 1000) // 5 minutes
|
||||
|
||||
const staleAgents = await db.query.agents.findMany({
|
||||
where: (agents, { lt }) => lt(agents.lastHeartbeat, staleThreshold),
|
||||
})
|
||||
|
||||
for (const agent of staleAgents) {
|
||||
logger.warn(`Cleaning up stale agent: ${agent.id}`)
|
||||
await this.deleteAgent(agent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start autoscaler
|
||||
setInterval(async () => {
|
||||
const manager = new AgentManager()
|
||||
await manager.autoScale()
|
||||
await manager.cleanupStaleAgents()
|
||||
}, 30000) // Every 30 seconds
|
||||
```
|
||||
|
||||
## Agent Logs Streaming
|
||||
|
||||
```typescript
|
||||
// api/routes/agents.ts
|
||||
import { Router } from 'express'
|
||||
import { K8sClient } from '../../services/kubernetes/client'
|
||||
import { db } from '../../db/client'
|
||||
import { agents } from '../../db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
const router = Router()
|
||||
const k8sClient = new K8sClient()
|
||||
|
||||
router.get('/:agentId/logs/stream', async (req, res) => {
|
||||
const { agentId } = req.params
|
||||
|
||||
const agent = await db.query.agents.findFirst({
|
||||
where: eq(agents.id, agentId),
|
||||
})
|
||||
|
||||
if (!agent) {
|
||||
return res.status(404).json({ error: 'Agent not found' })
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
|
||||
try {
|
||||
const logStream = await k8sClient.streamPodLogs(agent.k8sNamespace, agent.podName)
|
||||
|
||||
logStream.on('data', (chunk) => {
|
||||
res.write(`data: ${chunk.toString()}\n\n`)
|
||||
})
|
||||
|
||||
logStream.on('end', () => {
|
||||
res.end()
|
||||
})
|
||||
|
||||
req.on('close', () => {
|
||||
logStream.destroy()
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to stream logs' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
## Monitoring Agents
|
||||
|
||||
```bash
|
||||
# Ver todos los agentes
|
||||
kubectl get pods -n agents -l app=claude-agent
|
||||
|
||||
# Ver logs de un agente
|
||||
kubectl logs -n agents claude-agent-abc123 -f
|
||||
|
||||
# Entrar a un agente
|
||||
kubectl exec -it -n agents claude-agent-abc123 -- /bin/bash
|
||||
|
||||
# Ver recursos consumidos
|
||||
kubectl top pods -n agents
|
||||
```
|
||||
567
docs/05-agents/comunicacion.md
Normal file
567
docs/05-agents/comunicacion.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# Comunicación Agentes-Backend
|
||||
|
||||
## Arquitectura de Comunicación
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Claude Code Agent │
|
||||
│ (Pod en K8s) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
│ MCP Protocol
|
||||
│ (HTTP/JSON-RPC)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ MCP Server │
|
||||
│ (Backend Service) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────┐ ┌────────┐
|
||||
│ MySQL │ │ Gitea │
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
## MCP Protocol Implementation
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_next_task",
|
||||
"arguments": {
|
||||
"agentId": "agent-uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "{\"task\": {...}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Client en Agente
|
||||
|
||||
```typescript
|
||||
// agent/mcp-client.ts
|
||||
class MCPClient {
|
||||
private baseUrl: string
|
||||
private agentId: string
|
||||
|
||||
constructor(baseUrl: string, agentId: string) {
|
||||
this.baseUrl = baseUrl
|
||||
this.agentId = agentId
|
||||
}
|
||||
|
||||
async callTool(toolName: string, args: any) {
|
||||
const response = await fetch(`${this.baseUrl}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Agent-ID': this.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`MCP call failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message)
|
||||
}
|
||||
|
||||
return data.result
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
const response = await fetch(`${this.baseUrl}/rpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Agent-ID': this.agentId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
return data.result.tools
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const mcp = new MCPClient(
|
||||
process.env.MCP_SERVER_URL,
|
||||
process.env.AGENT_ID
|
||||
)
|
||||
|
||||
const task = await mcp.callTool('get_next_task', {
|
||||
agentId: process.env.AGENT_ID,
|
||||
})
|
||||
```
|
||||
|
||||
## Server-Side Handler
|
||||
|
||||
```typescript
|
||||
// backend: api/routes/mcp.ts
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { handleToolCall } from '../../services/mcp/handlers'
|
||||
import { tools } from '../../services/mcp/tools'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// JSON-RPC endpoint
|
||||
router.post('/rpc', async (req: Request, res: Response) => {
|
||||
const { jsonrpc, id, method, params } = req.body
|
||||
|
||||
if (jsonrpc !== '2.0') {
|
||||
return res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32600,
|
||||
message: 'Invalid Request',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const agentId = req.headers['x-agent-id'] as string
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(401).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Missing agent ID',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case 'tools/list':
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result: {
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
case 'tools/call':
|
||||
const { name, arguments: args } = params
|
||||
|
||||
logger.info(`MCP call from ${agentId}: ${name}`)
|
||||
|
||||
const result = await handleToolCall(name, {
|
||||
...args,
|
||||
agentId,
|
||||
})
|
||||
|
||||
return res.json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
})
|
||||
|
||||
default:
|
||||
return res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('MCP error:', error)
|
||||
|
||||
return res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
data: error.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
## Heartbeat System
|
||||
|
||||
### Agent-Side Heartbeat
|
||||
|
||||
```bash
|
||||
# In agent pod
|
||||
while true; do
|
||||
curl -s -X POST "$MCP_SERVER_URL/heartbeat" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: $AGENT_ID" \
|
||||
-d "{\"status\":\"idle\"}"
|
||||
sleep 30
|
||||
done &
|
||||
```
|
||||
|
||||
### Server-Side Heartbeat Handler
|
||||
|
||||
```typescript
|
||||
// api/routes/mcp.ts
|
||||
router.post('/heartbeat', async (req: Request, res: Response) => {
|
||||
const agentId = req.headers['x-agent-id'] as string
|
||||
const { status } = req.body
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(401).json({ error: 'Missing agent ID' })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(agents)
|
||||
.set({
|
||||
lastHeartbeat: new Date(),
|
||||
status: status || 'idle',
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update heartbeat' })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## WebSocket for Real-Time Updates
|
||||
|
||||
Alternativamente, para comunicación bidireccional en tiempo real:
|
||||
|
||||
```typescript
|
||||
// backend: api/websocket/agents.ts
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
|
||||
export function setupAgentWebSocket(io: SocketIOServer) {
|
||||
const agentNamespace = io.of('/agents')
|
||||
|
||||
agentNamespace.on('connection', (socket) => {
|
||||
const agentId = socket.handshake.query.agentId as string
|
||||
|
||||
console.log(`Agent connected: ${agentId}`)
|
||||
|
||||
// Join agent room
|
||||
socket.join(agentId)
|
||||
|
||||
// Heartbeat
|
||||
socket.on('heartbeat', async (data) => {
|
||||
await db.update(agents)
|
||||
.set({
|
||||
lastHeartbeat: new Date(),
|
||||
status: data.status,
|
||||
})
|
||||
.where(eq(agents.id, agentId))
|
||||
})
|
||||
|
||||
// Task updates
|
||||
socket.on('task_update', async (data) => {
|
||||
await db.update(tasks)
|
||||
.set({ state: data.state })
|
||||
.where(eq(tasks.id, data.taskId))
|
||||
|
||||
// Notify frontend
|
||||
io.emit('task:status_changed', {
|
||||
taskId: data.taskId,
|
||||
newState: data.state,
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Agent disconnected: ${agentId}`)
|
||||
})
|
||||
})
|
||||
|
||||
// Send task assignment to specific agent
|
||||
return {
|
||||
assignTask: (agentId: string, task: any) => {
|
||||
agentNamespace.to(agentId).emit('task_assigned', task)
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### JWT for Agents
|
||||
|
||||
```typescript
|
||||
// Generate agent token
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
export function generateAgentToken(agentId: string) {
|
||||
return jwt.sign(
|
||||
{
|
||||
agentId,
|
||||
type: 'agent',
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{
|
||||
expiresIn: '7d',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Verify middleware
|
||||
export function verifyAgentToken(req: Request, res: Response, next: NextFunction) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' })
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!)
|
||||
req.agentId = decoded.agentId
|
||||
next()
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### mTLS (Optional)
|
||||
|
||||
Para seguridad adicional, usar mTLS entre agentes y backend:
|
||||
|
||||
```yaml
|
||||
# Agent pod with client cert
|
||||
volumes:
|
||||
- name: agent-certs
|
||||
secret:
|
||||
secretName: agent-client-certs
|
||||
|
||||
volumeMounts:
|
||||
- name: agent-certs
|
||||
mountPath: /etc/certs
|
||||
readOnly: true
|
||||
|
||||
env:
|
||||
- name: MCP_CLIENT_CERT
|
||||
value: /etc/certs/client.crt
|
||||
- name: MCP_CLIENT_KEY
|
||||
value: /etc/certs/client.key
|
||||
```
|
||||
|
||||
## Retry & Error Handling
|
||||
|
||||
```typescript
|
||||
// agent/mcp-client-with-retry.ts
|
||||
class MCPClientWithRetry extends MCPClient {
|
||||
async callToolWithRetry(
|
||||
toolName: string,
|
||||
args: any,
|
||||
maxRetries = 3
|
||||
) {
|
||||
let lastError
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await this.callTool(toolName, args)
|
||||
} catch (error: any) {
|
||||
lastError = error
|
||||
console.error(`Attempt ${i + 1} failed:`, error.message)
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
// Exponential backoff
|
||||
await sleep(Math.pow(2, i) * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Circuit Breaker
|
||||
|
||||
```typescript
|
||||
// agent/circuit-breaker.ts
|
||||
class CircuitBreaker {
|
||||
private failures = 0
|
||||
private lastFailureTime = 0
|
||||
private state: 'closed' | 'open' | 'half-open' = 'closed'
|
||||
|
||||
private readonly threshold = 5
|
||||
private readonly timeout = 60000 // 1 minute
|
||||
|
||||
async call<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'open') {
|
||||
if (Date.now() - this.lastFailureTime > this.timeout) {
|
||||
this.state = 'half-open'
|
||||
} else {
|
||||
throw new Error('Circuit breaker is open')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
|
||||
if (this.state === 'half-open') {
|
||||
this.state = 'closed'
|
||||
this.failures = 0
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
this.failures++
|
||||
this.lastFailureTime = Date.now()
|
||||
|
||||
if (this.failures >= this.threshold) {
|
||||
this.state = 'open'
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const breaker = new CircuitBreaker()
|
||||
|
||||
const task = await breaker.call(() =>
|
||||
mcp.callTool('get_next_task', { agentId })
|
||||
)
|
||||
```
|
||||
|
||||
## Monitoring Communication
|
||||
|
||||
```typescript
|
||||
// backend: middleware/mcp-metrics.ts
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const metrics = {
|
||||
totalCalls: 0,
|
||||
successCalls: 0,
|
||||
failedCalls: 0,
|
||||
callDurations: [] as number[],
|
||||
}
|
||||
|
||||
export function mcpMetricsMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const start = Date.now()
|
||||
metrics.totalCalls++
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start
|
||||
metrics.callDurations.push(duration)
|
||||
|
||||
if (res.statusCode < 400) {
|
||||
metrics.successCalls++
|
||||
} else {
|
||||
metrics.failedCalls++
|
||||
}
|
||||
|
||||
logger.debug('MCP call metrics', {
|
||||
method: req.body?.method,
|
||||
agentId: req.headers['x-agent-id'],
|
||||
duration,
|
||||
status: res.statusCode,
|
||||
})
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
// Endpoint para ver métricas
|
||||
router.get('/metrics', (req, res) => {
|
||||
res.json({
|
||||
total: metrics.totalCalls,
|
||||
success: metrics.successCalls,
|
||||
failed: metrics.failedCalls,
|
||||
avgDuration:
|
||||
metrics.callDurations.reduce((a, b) => a + b, 0) /
|
||||
metrics.callDurations.length,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing MCP Communication
|
||||
|
||||
```typescript
|
||||
// test/mcp-client.test.ts
|
||||
import { MCPClient } from '../agent/mcp-client'
|
||||
|
||||
describe('MCP Client', () => {
|
||||
let client: MCPClient
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MCPClient('http://localhost:3100', 'test-agent')
|
||||
})
|
||||
|
||||
it('should list available tools', async () => {
|
||||
const tools = await client.listTools()
|
||||
expect(tools).toContainEqual(
|
||||
expect.objectContaining({ name: 'get_next_task' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should call tool successfully', async () => {
|
||||
const result = await client.callTool('heartbeat', {
|
||||
status: 'idle',
|
||||
})
|
||||
expect(result.content[0].text).toContain('success')
|
||||
})
|
||||
|
||||
it('should handle errors', async () => {
|
||||
await expect(
|
||||
client.callTool('invalid_tool', {})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
```
|
||||
452
docs/05-agents/mcp-tools.md
Normal file
452
docs/05-agents/mcp-tools.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# MCP Tools - Herramientas Disponibles para Agentes
|
||||
|
||||
Esta documentación detalla todas las herramientas MCP que los agentes Claude Code pueden usar para interactuar con el sistema AiWorker.
|
||||
|
||||
## get_next_task
|
||||
|
||||
Obtiene la siguiente tarea disponible de la cola y la asigna al agente.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"agentId": "uuid-of-agent",
|
||||
"capabilities": ["javascript", "react", "python"] // opcional
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "task-uuid",
|
||||
"title": "Implement user authentication",
|
||||
"description": "Create a JWT-based authentication system...",
|
||||
"priority": "high",
|
||||
"project": {
|
||||
"id": "project-uuid",
|
||||
"name": "My App",
|
||||
"giteaRepoUrl": "http://gitea/owner/my-app",
|
||||
"dockerImage": "myapp:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Ejemplo de uso**:
|
||||
```typescript
|
||||
// En Claude Code, el agente puede hacer:
|
||||
const task = await mcp.callTool('get_next_task', {
|
||||
agentId: process.env.AGENT_ID,
|
||||
capabilities: ['javascript', 'typescript', 'react']
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## update_task_status
|
||||
|
||||
Actualiza el estado de una tarea.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"status": "in_progress" | "needs_input" | "ready_to_test" | "completed",
|
||||
"metadata": {
|
||||
"durationMinutes": 45,
|
||||
"linesChanged": 250
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Estados válidos**:
|
||||
- `in_progress`: Agente trabajando activamente
|
||||
- `needs_input`: Agente necesita información del usuario
|
||||
- `ready_to_test`: Tarea completada, lista para testing
|
||||
- `completed`: Tarea completamente finalizada
|
||||
|
||||
---
|
||||
|
||||
## ask_user_question
|
||||
|
||||
Solicita información al usuario cuando el agente necesita clarificación.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"question": "Which authentication library should I use: Passport.js or NextAuth?",
|
||||
"context": "The task requires implementing OAuth authentication. I found two popular options..."
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Question sent to user",
|
||||
"questionId": "question-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
1. Cambia el estado de la tarea a `needs_input`
|
||||
2. Notifica al frontend vía WebSocket
|
||||
3. Usuario responde desde el dashboard
|
||||
4. Agente puede hacer polling con `check_question_response`
|
||||
|
||||
---
|
||||
|
||||
## check_question_response
|
||||
|
||||
Verifica si el usuario ha respondido una pregunta.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Output (sin respuesta)**:
|
||||
```json
|
||||
{
|
||||
"hasResponse": false,
|
||||
"message": "No response yet"
|
||||
}
|
||||
```
|
||||
|
||||
**Output (con respuesta)**:
|
||||
```json
|
||||
{
|
||||
"hasResponse": true,
|
||||
"response": "Use NextAuth, it integrates better with our Next.js stack",
|
||||
"question": "Which authentication library should I use..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## create_branch
|
||||
|
||||
Crea una nueva rama en Gitea para la tarea.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"branchName": "feature/user-auth" // opcional, se genera automático
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"branchName": "task-abc123-implement-user-authentication",
|
||||
"repoUrl": "http://gitea/owner/my-app"
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
- Si no se especifica `branchName`, se genera como: `task-{shortId}-{title-slugified}`
|
||||
- Se crea desde la rama default del proyecto (main/develop)
|
||||
- Se actualiza el campo `branchName` en la tarea
|
||||
|
||||
---
|
||||
|
||||
## create_pull_request
|
||||
|
||||
Crea un Pull Request en Gitea con los cambios de la tarea.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid",
|
||||
"title": "Implement JWT-based authentication",
|
||||
"description": "## Changes\n- Added JWT middleware\n- Created auth routes\n- Added tests\n\n## Test Plan\n- [ ] Test login flow\n- [ ] Test token refresh"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"prUrl": "http://gitea/owner/my-app/pulls/42",
|
||||
"prNumber": 42
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
- Crea PR desde la rama de la tarea hacia la rama default
|
||||
- Actualiza campos `prNumber` y `prUrl` en la tarea
|
||||
- Emite evento WebSocket `task:pr_created`
|
||||
|
||||
---
|
||||
|
||||
## trigger_preview_deploy
|
||||
|
||||
Despliega la tarea en un preview environment aislado en Kubernetes.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"previewUrl": "https://task-abc123.preview.aiworker.dev",
|
||||
"namespace": "preview-task-abc123"
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
1. Crea namespace en K8s: `preview-task-{shortId}`
|
||||
2. Deploya la aplicación con la imagen del proyecto
|
||||
3. Crea ingress con URL única
|
||||
4. Actualiza tarea a estado `ready_to_test`
|
||||
5. Guarda `previewUrl` y `previewNamespace` en la tarea
|
||||
|
||||
---
|
||||
|
||||
## get_task_details
|
||||
|
||||
Obtiene detalles completos de una tarea incluyendo preguntas pendientes.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"taskId": "task-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "task-uuid",
|
||||
"title": "Implement user authentication",
|
||||
"description": "...",
|
||||
"state": "needs_input",
|
||||
"branchName": "task-abc123-implement-user-authentication",
|
||||
"prUrl": "http://gitea/owner/my-app/pulls/42",
|
||||
"previewUrl": "https://task-abc123.preview.aiworker.dev",
|
||||
"questions": [
|
||||
{
|
||||
"id": "q-uuid",
|
||||
"question": "Which auth library?",
|
||||
"status": "pending",
|
||||
"askedAt": "2026-01-19T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"project": {
|
||||
"name": "My App",
|
||||
"giteaRepoUrl": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## log_activity
|
||||
|
||||
Registra actividad del agente en los logs del sistema.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"agentId": "agent-uuid",
|
||||
"level": "info" | "debug" | "warn" | "error",
|
||||
"message": "Starting task implementation",
|
||||
"metadata": {
|
||||
"taskId": "task-uuid",
|
||||
"operation": "code_generation",
|
||||
"filesModified": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Niveles de log**:
|
||||
- `debug`: Información detallada de debugging
|
||||
- `info`: Eventos normales (default)
|
||||
- `warn`: Situaciones que requieren atención
|
||||
- `error`: Errores que impidieron completar una operación
|
||||
|
||||
---
|
||||
|
||||
## heartbeat
|
||||
|
||||
Envía señal de vida para indicar que el agente está activo.
|
||||
|
||||
**Input**:
|
||||
```json
|
||||
{
|
||||
"agentId": "agent-uuid",
|
||||
"status": "idle" | "busy" | "error"
|
||||
}
|
||||
```
|
||||
|
||||
**Output**:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Comportamiento**:
|
||||
- Actualiza `lastHeartbeat` timestamp
|
||||
- Actualiza `status` del agente
|
||||
- Si no se recibe heartbeat por 5 minutos, el agente se marca como `offline`
|
||||
|
||||
---
|
||||
|
||||
## Flujo Típico de una Tarea
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
Agent->>MCP: get_next_task()
|
||||
MCP-->>Agent: task details
|
||||
Agent->>MCP: create_branch()
|
||||
Agent->>Agent: Work on task
|
||||
Agent->>?MCP: ask_user_question() (si necesita)
|
||||
Agent->>Agent: Wait for response
|
||||
Agent->>MCP: check_question_response()
|
||||
Agent->>Agent: Continue working
|
||||
Agent->>Git: commit & push
|
||||
Agent->>MCP: create_pull_request()
|
||||
Agent->>MCP: trigger_preview_deploy()
|
||||
Agent->>MCP: update_task_status("ready_to_test")
|
||||
```
|
||||
|
||||
## Ejemplo de Uso Completo
|
||||
|
||||
```typescript
|
||||
// Dentro del agente Claude Code
|
||||
async function processTask() {
|
||||
// 1. Get task
|
||||
const taskResult = await mcp.callTool('get_next_task', {
|
||||
agentId: process.env.AGENT_ID
|
||||
})
|
||||
|
||||
const task = JSON.parse(taskResult.content[0].text).task
|
||||
|
||||
if (!task) {
|
||||
console.log('No tasks available')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Working on: ${task.title}`)
|
||||
|
||||
// 2. Create branch
|
||||
const branchResult = await mcp.callTool('create_branch', {
|
||||
taskId: task.id
|
||||
})
|
||||
|
||||
const { branchName } = JSON.parse(branchResult.content[0].text)
|
||||
|
||||
// 3. Clone and checkout
|
||||
await exec(`git clone ${task.project.giteaRepoUrl} /workspace/task-${task.id}`)
|
||||
await exec(`cd /workspace/task-${task.id} && git checkout ${branchName}`)
|
||||
|
||||
// 4. Do the work...
|
||||
// (Claude Code generates and commits code)
|
||||
|
||||
// 5. Need clarification?
|
||||
if (needsClarification) {
|
||||
await mcp.callTool('ask_user_question', {
|
||||
taskId: task.id,
|
||||
question: 'Should I add error handling for network failures?',
|
||||
context: 'The API calls can fail...'
|
||||
})
|
||||
|
||||
// Wait for response
|
||||
let response
|
||||
while (!response) {
|
||||
await sleep(5000)
|
||||
const checkResult = await mcp.callTool('check_question_response', {
|
||||
taskId: task.id
|
||||
})
|
||||
const check = JSON.parse(checkResult.content[0].text)
|
||||
if (check.hasResponse) {
|
||||
response = check.response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create PR
|
||||
await mcp.callTool('create_pull_request', {
|
||||
taskId: task.id,
|
||||
title: task.title,
|
||||
description: `## Summary\nImplemented ${task.title}\n\n## Changes\n- Feature A\n- Feature B`
|
||||
})
|
||||
|
||||
// 7. Deploy preview
|
||||
await mcp.callTool('trigger_preview_deploy', {
|
||||
taskId: task.id
|
||||
})
|
||||
|
||||
// 8. Mark as done
|
||||
await mcp.callTool('update_task_status', {
|
||||
taskId: task.id,
|
||||
status: 'ready_to_test'
|
||||
})
|
||||
|
||||
console.log('Task completed!')
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Todos los tools pueden retornar errores:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "Error: Task not found"
|
||||
}],
|
||||
"isError": true
|
||||
}
|
||||
```
|
||||
|
||||
El agente debe manejar estos errores apropiadamente:
|
||||
|
||||
```typescript
|
||||
const result = await mcp.callTool('update_task_status', { ... })
|
||||
|
||||
if (result.isError) {
|
||||
console.error('Tool failed:', result.content[0].text)
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Para evitar abuse, los tools tienen rate limits:
|
||||
|
||||
- `get_next_task`: 1 por segundo
|
||||
- `ask_user_question`: 5 por minuto por tarea
|
||||
- `create_pr`: 1 por minuto
|
||||
- `trigger_preview_deploy`: 1 por minuto
|
||||
- Otros: 10 por segundo
|
||||
|
||||
Si se excede el rate limit, el tool retorna error 429.
|
||||
Reference in New Issue
Block a user