# Agente de diagnóstico/corrección (lab-agent) Wrapper headless (`scripts/lab-agent/lab-agent.sh`) que corre `claude -p` periódicamente para diagnosticar el estado del lab y, solo para un conjunto explícito y aprobado de recursos, ejecutar una acción correctiva de bajo riesgo. Este documento es tanto referencia técnica como decisión de gobernanza — la lista blanca de abajo es lo que autoriza al agente a actuar sin intervención humana, y debe poder auditarse. Estado: **timer de systemd ACTIVO desde 2026-08-26** (`lab-agent.timer`, usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de 15 a 60 min el mismo día para reducir costo). Script, Telegram y las unidades systemd, todo validado en vivo antes de activar. ## Lista blanca de acciones correctivas automáticas Aprobada 2026-08-25, antes de escribir una sola línea de código. ### 1. `kubectl rollout restart deployment/` | Condición | Valor | |---|---| | Trigger | Deployment con `readyReplicas < replicas` o condición `Available=False` durante **≥10 minutos consecutivos** | | Namespace elegible | `ecommerce` (todos los deployments detectados ahí en el momento de la corrida) | | Excepción | `medusa-deploy` — el deployment real de "commerce-backend" en la terminología de `CLAUDE.md` (confirmado en vivo 2026-08-25: no existe un deployment llamado literalmente `commerce-backend`) — solo elegible si además la hora **local** del host está entre **00:00–06:00** | | Namespaces excluidos siempre | `argocd`, `kube-system`, cualquier otro namespace de plataforma | ### 2. `docker restart ` | Condición | Valor | |---|---| | Contenedores elegibles | **Únicamente `gitea-runner`** | | Trigger | Contenedor no está en estado `running` durante ≥10 min, **o** la API de Gitea Actions reporta un run en estado `running` atascado por más de 10 min sin terminar (señal del incidente de runner zombie del 2026-08-16) | | Degradación segura | Si no hay `GITEA_TOKEN_FILE` configurado o la API falla, esa señal se omite — nunca dispara una acción sobre una lectura no verificada, solo queda la señal de "contenedor caído" | | Excluidos siempre | `gitea`, `nginxproxymanager`, `cloudflared` | ### 3. Fuera de alcance automático — SIEMPRE (solo diagnóstico + notificación) - Borrar cualquier recurso, escalar réplicas, tocar `ConfigMap`/`Secret`/configuración. - Reiniciar `gitea`, `nginxproxymanager`, `cloudflared`. - Cualquier acción sobre `medusa-deploy` fuera de la ventana 00:00–06:00. - Cualquier namespace/deployment no detectado explícitamente en `ecommerce`. ### 4. Límites obligatorios - **Rate limit:** máx. 1 acción correctiva por recurso cada 30 min. - **Sin reintento silencioso:** tras un primer intento cuyo problema no se resuelve, el recurso queda `blocked` — solo notificación, hasta que un humano limpie la entrada en `state.json` o el recurso se recupere solo. - **Logging completo:** cada decisión (actúe o no) se registra en `actions.jsonl` con timestamp, recurso, decisión, resultado y motivo. - **Notificación siempre:** toda acción correctiva o anomalía dispara notificación — nunca es silenciosa. ## Diseño de seguridad: por qué Claude nunca ejecuta comandos `claude -p` se invoca con `--allowedTools ""` (cero herramientas) y `--json-schema`, así que el modelo **nunca** ejecuta un comando — solo devuelve una decisión estructurada (`{"actions":[{"resource","decision","reason"}], "summary"}`). El script bash es el único que ejecuta `kubectl`/`docker`, y solo después de revalidar la decisión contra su propio cálculo de elegibilidad — nunca confía en el JSON de Claude por sí solo. Se descartó dar a Claude un `Bash` tool con un patrón tipo `Bash(kubectl rollout restart deployment/medusa-deploy -n ecommerce)` porque se comprobó en vivo (2026-08-25) que `--allowedTools` hace **coincidencia por prefijo, no exacta**: un patrón `Bash(echo pintest)` también permite ejecutar `echo pintest && echo INJECTED` o `echo pintest; rm -rf /`. Para un comando root-capaz eso no es un límite seguro. Con `--allowedTools ""`, el peor caso posible es que Claude proponga una decisión incorrecta en el JSON — y el wrapper la rechaza si el recurso no está en su propia lista de elegibles. El prompt también advierte contra prompt injection: los datos de diagnóstico (estado de pods, logs) podrían contener texto arbitrario si algo en el cluster está comprometido, e instruye a Claude a ignorar cualquier instrucción incrustada ahí. ## Autenticación: login de suscripción, no API key `ANTHROPIC_API_KEY_FILE` queda vacío por defecto — `claude -p` usa el login de suscripción ya activo del usuario que corre el script (`~/.claude/.credentials.json`), el mismo que usa una sesión interactiva normal. Esta decisión se tomó en vivo (2026-08-26) al confirmar que no había una API key real disponible; el diseño original pedía un secreto root-only para la API key, pero se simplificó porque: 1. No hacía falta una API key aparte — la suscripción ya cubre esto. 2. Elimina la necesidad de crear secretos root-only vía `sudo` (que además está bloqueado para una sesión de Claude Code interactiva por diseño — ver `feedback_scoped_root_access_pattern` en memoria del proyecto). **Consecuencia directa:** el servicio systemd (cuando se active) debe correr como usuario `devops`, no como `root` — root no tiene acceso a ese login. `CLAUDE_BIN`/`KUBECTL_BIN` apuntan por ruta absoluta a `/DATA/AppData/home-devops/.local/bin/` porque ese directorio no está en el `PATH` de root ni de systemd (confirmado en vivo: `sudo which claude` no lo encuentra). El único secreto que queda es `GITEA_TOKEN_FILE` (token de Gitea Actions, opcional, scope lectura), dueño `devops`, `600` — sin sudo. ## Prueba end-to-end (2026-08-26) Ejecutada contra el cluster real, rama `fix/lab-agent-diagnostico-headless`. Umbral bajado a 2 minutos solo para esta prueba (`UNHEALTHY_THRESHOLD_MIN`, valor real en producción: 10). Canario desechable: `deployment/test-agent-canary` en `ecommerce`, imagen `busybox:stable` con `command: ["sh","-c","exit 1"]` — garantiza `CrashLoopBackOff` predecible, sin relación con `catalog-api-deploy`/`frontend-deploy`/`medusa-deploy`. ### Bugs encontrados y corregidos durante la prueba 1. **Subshell perdía el resultado de la acción.** `execute_corrective_action` se invocaba como `result="$(execute_corrective_action "$resource")"` — al correr en un subshell, las variables `LAST_ACTION_CMD`/`STDOUT`/`STDERR` nunca volvían al script principal → crash con `unbound variable`. Corregido: la función ahora devuelve éxito/fallo por exit status, invocada directamente (sin `$(...)`) para que sus variables globales persistan. 2. **DRY_RUN contaminaba el estado real.** El wrapper marcaba `blocked=true` en `state.json` incluso en modo simulación, lo que habría bloqueado incorrectamente una corrida real posterior sobre el mismo recurso. Corregido: la actualización de `state.json` (bloqueo/rate-limit) solo ocurre fuera de `DRY_RUN`. 3. **`claude`/`kubectl` fuera del PATH de root/systemd** — ver sección de autenticación arriba. Corregido con `CLAUDE_BIN`/`KUBECTL_BIN` por ruta absoluta. ### Evidencia por paso **DRY_RUN (elegibilidad + decisión, sin tocar el cluster):** - 1ª corrida (recién detectado): `elegibilidad=not_eligible:bajo_umbral(0m<2m)` → Claude devolvió `notify_only` para los 5 recursos. - 2ª corrida (>2 min después): `elegibilidad=eligible` para `test-agent-canary` → Claude devolvió `decision:"restart"` solo para ese recurso, `notify_only` para los otros 4 (sanos). - Log: `ACCIÓN EJECUTADA: kubectl -n ecommerce rollout restart deployment/test-agent-canary -> OK`, pero verificado en el cluster real que **no hubo restart** (sin anotación `restartedAt`, mismo pod, misma edad). `actions.jsonl` registra `"dry_run": true`. **Ejecución real (`--live`):** - Misma decisión de Claude (`restart` para el canario, `notify_only` para el resto). - Verificado en el cluster: nueva anotación `kubectl.kubernetes.io/restartedAt` en el deployment, evento `ScalingReplicaSet` de un ReplicaSet **nuevo**, pod nuevo en otro nodo. - `actions.jsonl`: `"result": "success", "dry_run": false`. - `state.json`: `last_action_at` seteado, `blocked: true`. **Rate limit / sin reintento silencioso:** - El canario (siempre crashea por diseño) volvió a estar unhealthy sin intervención extra. - Corrida siguiente: `elegibilidad=not_eligible:blocked_tras_intento_previo_sin_recuperacion`. - Claude devolvió `notify_only` para **todos** los recursos, incluido el canario — ningún segundo restart propuesto ni ejecutado. - `state.json` sin cambios (mismo `last_action_at` que la corrida real anterior) — confirma que no hubo reintento. - Notificación real entregada vía ntfy.sh, confirmada leyendo el propio historial del topic (no solo el log del script). **Limpieza:** `deployment/test-agent-canary` eliminado, entrada correspondiente en `state.json` borrada. Los logs de la prueba (`actions.jsonl`, `run-*.log`) se conservan como evidencia auditable — no se purgaron. ## Notificaciones **Canal: Telegram Bot API** (reemplaza a ntfy, cambiado 2026-08-26 por preferencia — no por costo, ver sección de costo abajo). Bot creado manualmente vía [@BotFather](https://t.me/BotFather); el token vive en `TELEGRAM_BOT_TOKEN_FILE` (dueño `devops`, `600` — **no** root-only, mismo criterio que `GITEA_TOKEN_FILE`: el servicio corre como `devops` por el login de suscripción, un archivo `root:root` le sería ilegible). `TELEGRAM_CHAT_ID` no es secreto, va directo en `lab-agent.env`. El mensaje se manda como texto plano, sin `parse_mode`: el motivo que devuelve Claude es texto libre y puede traer `_`, `*`, `[`, `]` — con Markdown/MarkdownV2 esos caracteres rompen el parser de Telegram (`400 Bad Request`) si no se escapan con cuidado. Se prioriza entrega confiable sobre formato. **Validado en vivo 2026-08-26** con el mismo código que usa `notify_send()` (lectura del token, `sendMessage` vía `curl --data-urlencode`, formato `[title]\nmessage`): entrega confirmada (`"ok":true`) y contenido recibido tal cual en Telegram. Gotcha encontrado durante la prueba: el primer intento falló con `400 chat not found` porque el `chat_id` pegado correspondía a otro bot — el `chat_id` de un usuario es el mismo across bots, pero Telegram igual devuelve `chat not found` si *ese bot puntual* nunca recibió un mensaje de esa persona. Se resolvió confirmando el `chat_id` real vía `getUpdates` del bot correcto. **Formato:** cada notificación lleva un ícono según su naturaleza (✅ acción ok, 🛑 acción falló/rechazada, 🚨 alerta de seguridad, ❌ error operativo, 👀 solo diagnóstico) y el log completo de esa corrida va adjunto como documento (`sendDocument`), no solo como ruta — útil para revisar desde el celular sin acceso a shell. **Notificación silenciosa y respuestas en hilo (2026-08-26):** el nivel `info` (nada elegible, todo diagnóstico rutinario) se manda con `disable_notification=true` — no interrumpe; acciones, fallas y alertas de seguridad sí suenan. Cuando se ejecuta una acción correctiva, el `message_id` de Telegram queda guardado por recurso en `state.json` (`telegram_message_id`); si ese recurso se recupera solo en una corrida posterior, se manda un aviso silencioso como **respuesta** (hilo) al mensaje original antes de limpiar el estado — agrupa visualmente todo un incidente. Limitación conocida: si un humano desbloquea el recurso a mano borrando su entrada de `state.json` (ver más abajo), el `message_id` se pierde con esa entrada, así que ese caso puntual no queda threadeado — solo el "se recuperó solo" está cubierto hoy. Validado en vivo con el canario: acción real → `message_id` capturado → canario parcheado a una imagen sana → reply en hilo confirmado, silencioso, al recuperarse. ## Horario activo y costo **Pausa nocturna (`ACTIVE_HOURS_START`/`END`, hora local del host):** activo `06:00`–`23:00`, pausado `23:00`–`06:00`. El script chequea esto como lo primero que hace tras inicializar logging — si está fuera de horario, sale **antes** de tocar `kubectl`/`docker`/`curl` o invocar `claude -p`, así que la pausa ahorra el costo completo de la corrida, no solo la notificación. El timer de systemd además solo dispara dentro de esa ventana (`OnCalendar=*-*-* 06..22:00:00`) — la doble verificación es deliberada: si alguien edita la unidad de systemd sin tocar `lab-agent.env` (o corre el script a mano), el chequeo interno sigue protegiendo. **Tensión sin resolver con la ventana de `medusa-deploy`:** la ventana de auto-restart para `medusa-deploy` (`COMMERCE_BACKEND_WINDOW_*`, `00:00`–`06:00`) **no se solapa** con el horario activo del agente (`06:00`–`23:00`). Esto significa que, en la práctica, `medusa-deploy` nunca es candidato real a auto-restart: la única ventana en la que la política lo permitiría es exactamente cuando el agente está pausado y ni siquiera corre. La regla sigue viva en el código (por si el horario activo cambia más adelante) pero hoy es efectivamente inalcanzable. Pendiente de decisión: mover la ventana de `medusa-deploy` a que caiga dentro del horario activo, o aceptar que ese recurso queda cubierto solo por diagnóstico/notificación manual. **Estimación de costo mensual — actualizada 2026-08-26, frecuencia bajada de 15 a 60 min explícitamente para reducir costo:** | | | |---|---| | Corridas/día (17h activas ÷ 60 min) | 17 | | Costo por corrida | ~US$0.04–0.12 — **más alto por corrida que a 15 min**: el cache de prompt de Claude Code dura ~1h, y con corridas espaciadas 60 min casi siempre expira entre una y la siguiente, así que la mayoría de las corridas pagan cache "fría" en vez de "tibia" | | **Estimado mensual (× 30 días)** | **~US$20–60** (punto medio ~US$35) | Ojo con la intuición: bajar la frecuencia 4× (68→17 corridas/día) NO baja el costo 4× — el ahorro real es más cercano a 1.5–2× frente a la estimación anterior (~US$60–65/mes), porque cada corrida individual sale más cara al perder la ventaja del cache tibio. Sigue siendo menos costo total, solo que no linealmente. Basado en pocas corridas reales medidas — revisar el costo real después de la primera semana en producción (Telegram/Gitea Actions no cambian este número, el costo es 100% de la API de Claude). **Trade-off de latencia de detección:** con corridas cada 60 min en vez de 15, un problema que recién cruza el umbral de 10 minutos puede tardar hasta ~70 min en ser detectado y potencialmente corregido (antes era hasta ~25 min). Aceptado a cambio del ahorro de costo — si en la práctica resulta muy lento para algún incidente real, la frecuencia se puede volver a subir sin tocar nada más (`OnCalendar` en `lab-agent.timer`). ## Activación del timer (2026-08-26) Habilitado tras confirmación explícita del propietario del lab, viendo antes el costo mensual estimado. Unidad de usuario (`systemctl --user`, corre como `devops`, no `root`), con límites de CPU/memoria (`CPUQuota`, `MemoryMax`) — instalada en `/DATA/.config/systemd/user/`, no en el `~/.config/...` de una shell interactiva. **Gotcha encontrado y corregido en vivo:** el manager de `systemd --user` de `devops` arranca con `HOME=/DATA` (el valor de `/etc/passwd`), no `/DATA/AppData/home-devops` — ese override solo se aplica al abrir una shell interactiva vía `/etc/profile.d/zz-devops-home.sh`, que el manager de systemd nunca carga. Sin corregirlo, `claude -p` habría buscado `~/.claude/.credentials.json` en `/DATA/.claude/` (no existe) y la autenticación habría fallado en cada corrida del timer, silenciosamente, hasta la primera vez que alguien mirara el log. `lab-agent.service` fija `Environment=HOME=/DATA/AppData/home-devops` explícitamente para evitarlo. Se detectó y corrigió *antes* de habilitar el timer, corriendo el servicio manualmente vía `systemctl --user start` con el entorno real (no desde una shell interactiva) como último paso de validación. También requirió `sudo loginctl enable-linger devops` (una vez) — sin eso, `systemctl --user` ni siquiera conecta (no hay `/run/user/` sin una sesión de login o linger activo). **Pendiente, no bloqueante:** - Opcional: `GITEA_TOKEN_FILE` para activar la señal de "runner atascado" vía API de Gitea Actions (hoy se omite, degradación segura). - Decidir la tensión con la ventana de `medusa-deploy` (ver arriba) — sigue sin resolver, el timer se activó igual porque no bloquea el resto de la lista blanca.