Merge branch 'fix/lab-agent-telegram-horario' into main
Notificaciones vía Telegram (reemplaza ntfy.sh), pausa nocturna (06:00-23:00 hora local, con doble verificación script+systemd), timer de systemd activado (cada 60 min, bajado de 15 para reducir costo), formato con íconos + notificación silenciosa para info + respuestas en hilo al recuperarse + log adjunto. Incluye el fix de Environment=HOME= para systemd --user (el manager de devops arranca con HOME=/DATA, no con el override de shell interactiva). Todo validado en vivo antes de activar.
This commit is contained in:
commit
25276ad5b1
5 files changed
+286
-47
No files matched your search
+59
-9
@@ -7,11 +7,14 @@ acción correctiva de bajo riesgo. Ver la lista blanca completa, la
|
||||
justificación de gobernanza y la evidencia de la prueba end-to-end en
|
||||
`apps-registry` → `docs/arquitectura/agente-monitoreo.md`.
|
||||
|
||||
**Estado (2026-08-26): script y prueba end-to-end validados en vivo**
|
||||
contra el cluster real (deployment canario desechable, ciclo completo
|
||||
DRY_RUN → ejecución real → rate limit/bloqueo → limpieza). El timer de
|
||||
systemd (Paso 4) **todavía no está activo** — pendiente de tu
|
||||
confirmación explícita.
|
||||
**Estado (2026-08-26): timer de systemd ACTIVO** (`lab-agent.timer`,
|
||||
usuario `devops`, cada 60 min entre 06:00–23:00 hora local — bajado de
|
||||
15 a 60 min el 2026-08-26 para reducir costo). Validado
|
||||
en vivo contra el cluster real: DRY_RUN → ejecución real → rate
|
||||
limit/bloqueo → recuperación con hilo en Telegram → limpieza, y
|
||||
corrida manual vía `systemctl --user start` con el entorno real del
|
||||
timer antes de habilitarlo. Ver `docs/arquitectura/agente-monitoreo.md`
|
||||
para la evidencia completa y el costo mensual estimado.
|
||||
|
||||
## Diseño de seguridad (por qué Claude no ejecuta comandos directamente)
|
||||
|
||||
@@ -118,6 +121,30 @@ Si no existe, el agente sigue funcionando pero pierde la señal de
|
||||
"runner atascado" vía API de Gitea Actions (solo detecta el contenedor
|
||||
caído/reiniciando).
|
||||
|
||||
**Notificaciones (Telegram, reemplaza a ntfy.sh desde 2026-08-26):**
|
||||
creá el bot con [@BotFather](https://t.me/BotFather) (`/newbot`) y
|
||||
obtené tu `chat_id` (mandale un mensaje al bot primero, después
|
||||
`https://api.telegram.org/bot<TOKEN>/getUpdates`). El token va en un
|
||||
archivo aparte, dueño `devops` — **no** root-only pese al patrón
|
||||
original: el servicio corre como `devops` (necesita su login de
|
||||
suscripción para `claude -p`), así que un archivo `root:root 600` le
|
||||
sería ilegible, mismo criterio ya aplicado a `GITEA_TOKEN_FILE`.
|
||||
|
||||
```bash
|
||||
mkdir -p "$(dirname "$TELEGRAM_BOT_TOKEN_FILE")"
|
||||
echo "<tu-token-de-botfather>" > "$TELEGRAM_BOT_TOKEN_FILE"
|
||||
chmod 600 "$TELEGRAM_BOT_TOKEN_FILE"
|
||||
# y en lab-agent.env: TELEGRAM_CHAT_ID=<tu-chat-id> (no es secreto)
|
||||
```
|
||||
|
||||
**Horario activo:** `ACTIVE_HOURS_START`/`ACTIVE_HOURS_END` (hora local,
|
||||
default `06:00`–`23:00`). Fuera de esa ventana el script sale antes de
|
||||
tocar kubectl/docker/curl o invocar `claude -p` — no gasta nada, no
|
||||
solo omite la notificación. **Ojo:** esta ventana no se solapa con
|
||||
`COMMERCE_BACKEND_WINDOW_*` (`00:00`–`06:00`) — mientras el agente está
|
||||
pausado, `medusa-deploy` nunca es elegible para auto-restart en la
|
||||
práctica. Ver `docs/arquitectura/agente-monitoreo.md`.
|
||||
|
||||
## Probar
|
||||
|
||||
```bash
|
||||
@@ -132,8 +159,31 @@ ejecuta de verdad (confirmado con `kubectl get events` y la anotación
|
||||
`restartedAt`), y una segunda corrida sobre el mismo recurso caído
|
||||
respeta el bloqueo (`notify_only`, sin reintento). Dos bugs reales se
|
||||
encontraron y corrigieron en esa prueba — ver el detalle en
|
||||
`docs/arquitectura/agente-monitoreo.md`.
|
||||
`docs/arquitectura/agente-monitoreo.md`. Telegram (silencioso para
|
||||
`info`, hilo de respuestas al recuperarse) también se validó en vivo
|
||||
con el mismo canario.
|
||||
|
||||
**No actives el timer de systemd (Paso 4) todavía** — requiere tu
|
||||
confirmación explícita por separado, aunque la prueba end-to-end ya
|
||||
pasó.
|
||||
## Instalar el timer de systemd
|
||||
|
||||
Unidades de **usuario** (`systemctl --user`), corren como `devops` —
|
||||
necesario porque `claude -p` usa su login de suscripción. Ojo: el
|
||||
manager de `systemd --user` de `devops` arranca con `HOME=/DATA` (el
|
||||
de `/etc/passwd`), no `/DATA/AppData/home-devops` — por eso
|
||||
`lab-agent.service` fija `Environment=HOME=...` explícitamente; sin
|
||||
eso, `claude -p` no encuentra las credenciales. Los archivos van en
|
||||
`/DATA/.config/systemd/user/`, no en el `~/.config/...` de una shell
|
||||
interactiva (que resuelve a otra ruta por el mismo motivo).
|
||||
|
||||
```bash
|
||||
mkdir -p /DATA/.config/systemd/user
|
||||
cp systemd/lab-agent.service systemd/lab-agent.timer /DATA/.config/systemd/user/
|
||||
sudo loginctl enable-linger devops # una vez — si no, systemctl --user ni conecta
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u devops)
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start lab-agent.service # probar una corrida manual primero
|
||||
systemctl --user enable --now lab-agent.timer
|
||||
systemctl --user list-timers lab-agent.timer
|
||||
```
|
||||
|
||||
Activo desde 2026-08-26. Ver el costo mensual estimado y el detalle
|
||||
completo en `docs/arquitectura/agente-monitoreo.md`.
|
||||
@@ -62,10 +62,25 @@ GITEA_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token
|
||||
CLAUDE_MODEL=claude-sonnet-5
|
||||
CLAUDE_MAX_BUDGET_USD=0.50
|
||||
|
||||
# --- Notificaciones (Paso 3 — dejar vacío hasta tener un canal real) ---
|
||||
# Ej. self-hosted: https://ntfy.cruzcloud.net/lab-agent
|
||||
# ntfy.sh público: https://ntfy.sh/<topic-privado-dificil-de-adivinar>
|
||||
NTFY_URL=
|
||||
# --- Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26) ---
|
||||
# Token del bot: creado con @BotFather, va en un archivo aparte (ver
|
||||
# README.md). NO root-only pese al patrón original — el servicio corre
|
||||
# como `devops` (necesita el login de suscripción de ese usuario para
|
||||
# claude -p), así que un archivo root:root 600 sería ilegible para él.
|
||||
# Mismo criterio ya aplicado a GITEA_TOKEN_FILE.
|
||||
TELEGRAM_BOT_TOKEN_FILE=/DATA/AppData/k3d-lab/lab-agent/secrets/telegram_bot_token
|
||||
# chat_id NO es secreto (no autentica nada sin el token) — va acá directo.
|
||||
TELEGRAM_CHAT_ID=
|
||||
|
||||
# --- Horario activo (hora LOCAL del host) ---
|
||||
# Fuera de esta ventana el script aborta ANTES de tocar kubectl/docker/
|
||||
# curl o invocar claude -p — pausa nocturna para no acumular costo de
|
||||
# API. OJO: esta ventana NO se solapa con COMMERCE_BACKEND_WINDOW_*
|
||||
# (00:00-06:00) — mientras el agente está pausado, medusa-deploy nunca
|
||||
# es elegible para auto-restart en la práctica, aunque la regla siga
|
||||
# activa en el código. Ver docs/arquitectura/agente-monitoreo.md.
|
||||
ACTIVE_HOURS_START=06:00
|
||||
ACTIVE_HOURS_END=23:00
|
||||
|
||||
# --- Pruebas controladas (Paso 5) ---
|
||||
# DRY_RUN=1: calcula elegibilidad y decisión igual que en producción,
|
||||
|
||||
+167
-34
@@ -51,7 +51,18 @@ source "$ENV_FILE"
|
||||
: "${GITEA_TOKEN_FILE:=/DATA/AppData/k3d-lab/lab-agent/secrets/gitea_token}"
|
||||
: "${CLAUDE_MODEL:=claude-sonnet-5}"
|
||||
: "${CLAUDE_MAX_BUDGET_USD:=0.50}"
|
||||
: "${NTFY_URL:=}"
|
||||
# Notificaciones vía Telegram Bot API (reemplaza a ntfy.sh, 2026-08-26).
|
||||
# TELEGRAM_BOT_TOKEN_FILE: secreto, NO versionado. TELEGRAM_CHAT_ID no es
|
||||
# secreto (solo identifica a quién mandar, no autentica nada por sí solo).
|
||||
: "${TELEGRAM_BOT_TOKEN_FILE:=}"
|
||||
: "${TELEGRAM_CHAT_ID:=}"
|
||||
# Horario activo (hora LOCAL del host): fuera de esta ventana el script
|
||||
# aborta ANTES de tocar kubectl/docker/curl o invocar claude -p — pausa
|
||||
# nocturna para no acumular costo de API sin necesidad. Ver
|
||||
# docs/arquitectura/agente-monitoreo.md para el tradeoff con la ventana
|
||||
# de medusa-deploy (00:00-06:00).
|
||||
: "${ACTIVE_HOURS_START:=06:00}"
|
||||
: "${ACTIVE_HOURS_END:=23:00}"
|
||||
: "${DRY_RUN:=1}"
|
||||
|
||||
for arg in "$@"; do
|
||||
@@ -71,12 +82,36 @@ ACTIONS_LOG="${LOG_DIR}/actions.jsonl"
|
||||
exec > >(tee -a "$RUN_LOG") 2>&1
|
||||
|
||||
iso_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
log() { echo "[$(iso_now)] $*"; }
|
||||
log() { echo "[$(iso_now)] $*" >&2; } # stderr a propósito: el stdout de una función
|
||||
# queda limpio para $(...) (ej. capturar un
|
||||
# message_id de Telegram) sin que un log()
|
||||
# interno se cuele en la captura. Va al mismo
|
||||
# RUN_LOG igual, por el "exec > tee 2>&1" de arriba.
|
||||
|
||||
trap 'log "ERROR inesperado en línea $LINENO (comando: $BASH_COMMAND)"' ERR
|
||||
|
||||
log "=== lab-agent inicio (DRY_RUN=${DRY_RUN}) ==="
|
||||
|
||||
in_time_window() { # start end (HH:MM, hora local) -> 0 si "ahora" cae adentro
|
||||
local start="$1" end="$2" now_hm
|
||||
now_hm="$(date +%H:%M)"
|
||||
if [[ "$start" < "$end" ]]; then
|
||||
[[ ( "$now_hm" > "$start" || "$now_hm" == "$start" ) && "$now_hm" < "$end" ]]
|
||||
else
|
||||
[[ "$now_hm" > "$start" || "$now_hm" == "$start" || "$now_hm" < "$end" ]]
|
||||
fi
|
||||
}
|
||||
|
||||
# Pausa nocturna: si estamos fuera del horario activo, salir ANTES de tocar
|
||||
# kubectl/docker/curl o invocar claude -p — ni siquiera se gasta el costo
|
||||
# de diagnóstico, no solo el de la llamada a claude. Sin notificación (sería
|
||||
# spam cada 15 min durante toda la pausa) — solo queda en el log del run.
|
||||
if ! in_time_window "$ACTIVE_HOURS_START" "$ACTIVE_HOURS_END"; then
|
||||
log "fuera de horario activo (${ACTIVE_HOURS_START}-${ACTIVE_HOURS_END} hora local) — se omite esta corrida sin costo de API"
|
||||
log "=== lab-agent fin (omitido por horario) ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for c in "$KUBECTL_BIN" docker curl jq flock "$CLAUDE_BIN" date; do
|
||||
command -v "$c" >/dev/null 2>&1 || { log "FATAL: falta comando requerido '$c'"; exit 1; }
|
||||
done
|
||||
@@ -127,6 +162,14 @@ mark_action_taken() { # resource -> preserva first_unhealthy_at, marca last_acti
|
||||
state_write "$resource" "$new_state"
|
||||
}
|
||||
|
||||
attach_telegram_message_id() { # resource message_id -> guarda el id para poder responder en hilo cuando se recupere
|
||||
local resource="$1" msg_id="$2" prev new_state
|
||||
[[ -z "$msg_id" ]] && return 0
|
||||
prev="$(state_read "$resource")"
|
||||
new_state="$(jq -nc --argjson prev "$prev" --arg mid "$msg_id" '$prev + {telegram_message_id: ($mid | tonumber)}')"
|
||||
state_write "$resource" "$new_state"
|
||||
}
|
||||
|
||||
log_action() { # resource decision result reason
|
||||
local resource="$1" decision="$2" result="$3" reason="$4"
|
||||
jq -nc --arg ts "$(iso_now)" --arg resource "$resource" --arg decision "$decision" \
|
||||
@@ -139,15 +182,74 @@ log_action() { # resource decision result reason
|
||||
# Notificación (Paso 3 — no-op documentado hasta que NTFY_URL esté configurado)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
notify_send() { # title message
|
||||
local title="$1" message="$2"
|
||||
if [[ -z "$NTFY_URL" ]]; then
|
||||
log "NOTIFY (sin canal configurado aún — Paso 3) [$title]"
|
||||
notify_level_icon() { # level -> emoji
|
||||
case "$1" in
|
||||
error) echo "❌" ;;
|
||||
security) echo "🚨" ;;
|
||||
action_ok) echo "✅" ;;
|
||||
action_fail) echo "🛑" ;;
|
||||
info) echo "👀" ;;
|
||||
*) echo "🤖" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
notify_send() { # level title message [reply_to_message_id] -> imprime el message_id enviado (stdout), o nada si falló/no configurado
|
||||
local level="$1" title="$2" message="$3" reply_to="${4:-}" icon
|
||||
icon="$(notify_level_icon "$level")"
|
||||
if [[ ! -r "$TELEGRAM_BOT_TOKEN_FILE" || -z "$TELEGRAM_CHAT_ID" ]]; then
|
||||
log "NOTIFY (Telegram no configurado — falta TELEGRAM_BOT_TOKEN_FILE/TELEGRAM_CHAT_ID) [${icon} ${title}]"
|
||||
log "$message"
|
||||
return 0
|
||||
fi
|
||||
if ! curl -s -m 10 -H "Title: ${title}" -d "$message" "$NTFY_URL" >/dev/null 2>&1; then
|
||||
log "WARN: fallo al enviar notificación a NTFY_URL"
|
||||
local token text resp silent="false"
|
||||
# Silenciosa (sin sonido/vibración) solo para "info" — ruido de rutina
|
||||
# (nada elegible, todo sano) no debería interrumpir; fallas/acciones/
|
||||
# alertas de seguridad sí hacen sonido.
|
||||
[[ "$level" == "info" ]] && silent="true"
|
||||
token="$(<"$TELEGRAM_BOT_TOKEN_FILE")"
|
||||
# Texto plano, sin parse_mode: el "reason" de claude es texto libre y
|
||||
# puede traer _, *, [, ] etc. que rompen el parser Markdown de Telegram
|
||||
# (400 Bad Request) — más vale entrega confiable que formato. Los
|
||||
# emojis andan bien en texto plano, no dependen de parse_mode.
|
||||
text="🤖 lab-agent
|
||||
${icon} ${title}
|
||||
──────────────
|
||||
${message}"
|
||||
text="${text:0:4000}"
|
||||
local curl_args=(-s -m 10 -X POST "https://api.telegram.org/bot${token}/sendMessage"
|
||||
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}"
|
||||
--data-urlencode "text=${text}"
|
||||
--data-urlencode "disable_notification=${silent}")
|
||||
[[ -n "$reply_to" ]] && curl_args+=(--data-urlencode "reply_to_message_id=${reply_to}")
|
||||
resp="$(curl "${curl_args[@]}" 2>&1)"
|
||||
token=""
|
||||
if [[ "$resp" == *'"ok":true'* ]]; then
|
||||
jq -r '.result.message_id // empty' <<<"$resp" 2>/dev/null
|
||||
else
|
||||
log "WARN: fallo al enviar notificación a Telegram: ${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
notify_attach_log() { # caption -> adjunta RUN_LOG como documento (best-effort)
|
||||
local caption="$1"
|
||||
[[ -r "$TELEGRAM_BOT_TOKEN_FILE" && -n "$TELEGRAM_CHAT_ID" ]] || return 0
|
||||
if [[ ! -r "$RUN_LOG" ]]; then
|
||||
log "WARN: no se pudo adjuntar el log a Telegram, no es legible: ${RUN_LOG}"
|
||||
return 0
|
||||
fi
|
||||
local token resp
|
||||
token="$(<"$TELEGRAM_BOT_TOKEN_FILE")"
|
||||
# sendDocument, no sendMessage: el log completo va como archivo, no como
|
||||
# texto. Nota: al momento de subirlo el archivo todavía se está
|
||||
# escribiendo (exec > tee), así que le faltan sus últimas 2-3 líneas
|
||||
# (el cierre "=== lab-agent fin ===") — cosmético, no afecta el resto.
|
||||
resp="$(curl -s -m 30 -X POST "https://api.telegram.org/bot${token}/sendDocument" \
|
||||
-F "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||
-F "caption=${caption:0:1000}" \
|
||||
-F "document=@${RUN_LOG}" 2>&1)"
|
||||
token=""
|
||||
if [[ "$resp" != *'"ok":true'* ]]; then
|
||||
log "WARN: fallo al adjuntar el log a Telegram: ${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -161,15 +263,7 @@ DEPLOY_JSON="$("$KUBECTL_BIN" -n "$NAMESPACE" get deployment -o json 2>/dev/null
|
||||
mapfile -t DETECTED_DEPLOYMENTS < <(jq -r '.items[].metadata.name' <<<"$DEPLOY_JSON")
|
||||
|
||||
in_commerce_backend_window() {
|
||||
local now_hm start end
|
||||
now_hm="$(date +%H:%M)"
|
||||
start="$COMMERCE_BACKEND_WINDOW_START"
|
||||
end="$COMMERCE_BACKEND_WINDOW_END"
|
||||
if [[ "$start" < "$end" ]]; then
|
||||
[[ ( "$now_hm" > "$start" || "$now_hm" == "$start" ) && "$now_hm" < "$end" ]]
|
||||
else
|
||||
[[ "$now_hm" > "$start" || "$now_hm" == "$start" || "$now_hm" < "$end" ]]
|
||||
fi
|
||||
in_time_window "$COMMERCE_BACKEND_WINDOW_START" "$COMMERCE_BACKEND_WINDOW_END"
|
||||
}
|
||||
|
||||
# devuelve: "eligible" | "not_eligible:<motivo>"
|
||||
@@ -183,7 +277,20 @@ compute_eligibility() { # resource_key is_unhealthy window_ok
|
||||
blocked="$(jq -r '.blocked // false' <<<"$state")"
|
||||
|
||||
if [[ "$is_unhealthy" != "1" ]]; then
|
||||
[[ -n "$first_unhealthy_at" ]] && state_clear "$resource_key"
|
||||
if [[ -n "$first_unhealthy_at" ]]; then
|
||||
local prev_msg_id
|
||||
prev_msg_id="$(jq -r '.telegram_message_id // empty' <<<"$state")"
|
||||
# Si hubo una acción notificada sobre este recurso y ahora se
|
||||
# recuperó solo, avisar como respuesta al mensaje original (hilo).
|
||||
# >/dev/null a propósito: no necesitamos el message_id de ESTE aviso,
|
||||
# y notify_send también se usa en otros lados vía $(...) -- no
|
||||
# mezclar su stdout con el "not_eligible:healthy" que devuelve esta
|
||||
# función.
|
||||
if [[ -n "$prev_msg_id" ]]; then
|
||||
notify_send "info" "Recuperado" "✅ ${resource_key} volvió a estar sano por su cuenta." "$prev_msg_id" >/dev/null
|
||||
fi
|
||||
state_clear "$resource_key"
|
||||
fi
|
||||
echo "not_eligible:healthy"
|
||||
return
|
||||
fi
|
||||
@@ -443,7 +550,7 @@ CLAUDE_AUTH_ENV=()
|
||||
if [[ -n "$ANTHROPIC_API_KEY_FILE" ]]; then
|
||||
if [[ ! -r "$ANTHROPIC_API_KEY_FILE" ]]; then
|
||||
log "FATAL: ANTHROPIC_API_KEY_FILE está configurado (${ANTHROPIC_API_KEY_FILE}) pero no se puede leer"
|
||||
notify_send "lab-agent: error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}."
|
||||
notify_send error "Error de configuración" "Falta o no es legible ${ANTHROPIC_API_KEY_FILE}."
|
||||
exit 1
|
||||
fi
|
||||
CLAUDE_AUTH_ENV=("ANTHROPIC_API_KEY=$(<"$ANTHROPIC_API_KEY_FILE")")
|
||||
@@ -465,7 +572,8 @@ CLAUDE_JSON="$(
|
||||
2>>"$RUN_LOG"
|
||||
)" || {
|
||||
log "FATAL: la invocación a claude -p falló"
|
||||
notify_send "lab-agent: error" "claude -p terminó con error, ver ${RUN_LOG}"
|
||||
notify_send error "claude -p falló" "La invocación terminó con error, ver log adjunto"
|
||||
notify_attach_log "❌ claude -p falló"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -476,19 +584,21 @@ IS_ERROR="$(jq -r '.is_error // false' <<<"$CLAUDE_JSON")"
|
||||
PERMISSION_DENIALS="$(jq -c '.permission_denials // []' <<<"$CLAUDE_JSON")"
|
||||
if [[ "$PERMISSION_DENIALS" != "[]" ]]; then
|
||||
log "ALERTA: claude intentó usar herramientas pese a --allowedTools \"\": ${PERMISSION_DENIALS}"
|
||||
notify_send "lab-agent: ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas: ${PERMISSION_DENIALS}"
|
||||
notify_send security "ALERTA de seguridad" "El modelo intentó invocar herramientas denegadas pese a --allowedTools \"\": ${PERMISSION_DENIALS}"
|
||||
fi
|
||||
|
||||
if [[ "$IS_ERROR" == "true" ]]; then
|
||||
log "FATAL: claude -p devolvió is_error=true"
|
||||
notify_send "lab-agent: error" "claude -p devolvió un error, ver ${RUN_LOG}"
|
||||
notify_send error "claude -p devolvió error" "is_error=true, ver log adjunto"
|
||||
notify_attach_log "❌ claude -p devolvió error"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DECISION_JSON="$(jq -r '.structured_output // empty' <<<"$CLAUDE_JSON")"
|
||||
if [[ -z "$DECISION_JSON" ]]; then
|
||||
log "FATAL: no se recibió structured_output válido"
|
||||
notify_send "lab-agent: error" "Respuesta sin structured_output, ver ${RUN_LOG}"
|
||||
notify_send error "Respuesta sin structured_output" "claude -p no devolvió salida estructurada válida, ver log adjunto"
|
||||
notify_attach_log "❌ Respuesta sin structured_output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -552,6 +662,8 @@ execute_corrective_action() { # resource -> exit 0=ok/1=fail, deja LAST_ACTION_*
|
||||
|
||||
NOTIFY_LINES=()
|
||||
ACTIONS_TAKEN=0
|
||||
ACTIONS_FAILED=0
|
||||
ACTED_RESOURCES=() # recursos con decision=="restart" este ciclo -> para hilo de Telegram
|
||||
|
||||
while IFS= read -r action_json; do
|
||||
[[ -z "$action_json" ]] && continue
|
||||
@@ -564,13 +676,15 @@ while IFS= read -r action_json; do
|
||||
if [[ "$decision" == "restart" && "$eligibility" != "eligible" ]]; then
|
||||
log "ANOMALÍA: claude propuso restart en '${resource}' pero el wrapper lo marca '${eligibility}' — RECHAZADO, no se ejecuta"
|
||||
log_action "$resource" "restart_rejected" "rejected_not_eligible" "claude_reason=${claude_reason} | wrapper_eligibility=${eligibility}"
|
||||
NOTIFY_LINES+=("[RECHAZADO] ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}")
|
||||
ACTIONS_FAILED=1
|
||||
NOTIFY_LINES+=("🛑 Rechazado — ${resource}: claude propuso restart pero no es elegible (${eligibility}). Motivo de claude: ${claude_reason}")
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$decision" == "restart" ]]; then
|
||||
if execute_corrective_action "$resource"; then result="ok"; else result="fail"; fi
|
||||
ACTIONS_TAKEN=1
|
||||
ACTED_RESOURCES+=("$resource")
|
||||
|
||||
# Tras cualquier intento REAL (éxito o fallo), marcar blocked=true: un
|
||||
# solo intento automático por incidente, sin reintentos silenciosos.
|
||||
@@ -583,17 +697,20 @@ while IFS= read -r action_json; do
|
||||
if [[ "$result" == "ok" ]]; then
|
||||
log "ACCIÓN EJECUTADA: ${LAST_ACTION_CMD} -> OK"
|
||||
log_action "$resource" "restart" "success" "$claude_reason"
|
||||
NOTIFY_LINES+=("[ACCIÓN OK] ${resource}: ${LAST_ACTION_CMD}. Motivo: ${claude_reason}")
|
||||
NOTIFY_LINES+=("✅ Acción ejecutada — ${resource}: ${LAST_ACTION_CMD}
|
||||
Motivo: ${claude_reason}")
|
||||
else
|
||||
log "ACCIÓN FALLIDA: ${LAST_ACTION_CMD:-N/A} -> ${LAST_ACTION_STDERR}"
|
||||
log_action "$resource" "restart" "failure" "$claude_reason | stderr=${LAST_ACTION_STDERR}"
|
||||
NOTIFY_LINES+=("[ACCIÓN FALLÓ] ${resource}: ${LAST_ACTION_STDERR}. Queda bloqueado hasta que un humano lo revise (no se reintentará solo).")
|
||||
ACTIONS_FAILED=1
|
||||
NOTIFY_LINES+=("🛑 Acción falló — ${resource}: ${LAST_ACTION_STDERR}
|
||||
Queda bloqueado hasta revisión humana, no se reintenta solo.")
|
||||
fi
|
||||
else
|
||||
log "notify_only: ${resource} — ${claude_reason}"
|
||||
log_action "$resource" "notify_only" "n/a" "$claude_reason"
|
||||
if [[ "$eligibility" != "not_eligible:healthy" ]]; then
|
||||
NOTIFY_LINES+=("[SOLO DIAGNÓSTICO] ${resource} (${eligibility}): ${claude_reason}")
|
||||
NOTIFY_LINES+=("👀 Diagnóstico — ${resource} (${eligibility}): ${claude_reason}")
|
||||
fi
|
||||
fi
|
||||
done < <(jq -c '.actions[]?' <<<"$DECISION_JSON")
|
||||
@@ -603,13 +720,29 @@ done < <(jq -c '.actions[]?' <<<"$DECISION_JSON")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [[ "${#NOTIFY_LINES[@]}" -gt 0 || "$ACTIONS_TAKEN" == "1" ]]; then
|
||||
MSG="Resumen: ${CLAUDE_SUMMARY}"$'\n\n'
|
||||
for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n'; done
|
||||
MSG="${MSG}"$'\n'"log completo: ${RUN_LOG}"
|
||||
title="lab-agent"
|
||||
[[ "$ACTIONS_TAKEN" == "1" ]] && title="lab-agent: acción correctiva ejecutada"
|
||||
[[ "$DRY_RUN" == "1" ]] && title="[DRY_RUN] ${title}"
|
||||
notify_send "$title" "$MSG"
|
||||
MSG="📋 Resumen: ${CLAUDE_SUMMARY}"$'\n\n'
|
||||
for l in "${NOTIFY_LINES[@]}"; do MSG="${MSG}${l}"$'\n\n'; done
|
||||
MSG="${MSG}📄 log completo adjunto abajo"
|
||||
|
||||
if [[ "$ACTIONS_FAILED" == "1" ]]; then
|
||||
level="action_fail"; title="Acción falló o fue rechazada"
|
||||
elif [[ "$ACTIONS_TAKEN" == "1" ]]; then
|
||||
level="action_ok"; title="Acción correctiva ejecutada"
|
||||
else
|
||||
level="info"; title="Novedades del ciclo"
|
||||
fi
|
||||
[[ "$DRY_RUN" == "1" ]] && title="🧪 [DRY_RUN] ${title}"
|
||||
SENT_MSG_ID="$(notify_send "$level" "$title" "$MSG")"
|
||||
notify_attach_log "$(notify_level_icon "$level") ${title}"
|
||||
# Guardar el message_id contra cada recurso accionado este ciclo, para
|
||||
# poder responder en hilo el día que se recupere solo (ver
|
||||
# compute_eligibility). No aplica en DRY_RUN, ya que no se toca el
|
||||
# estado real en modo simulación.
|
||||
if [[ -n "$SENT_MSG_ID" && "$DRY_RUN" != "1" ]]; then
|
||||
for r in "${ACTED_RESOURCES[@]}"; do
|
||||
attach_telegram_message_id "$r" "$SENT_MSG_ID"
|
||||
done
|
||||
fi
|
||||
else
|
||||
log "Sin anomalías ni acciones este ciclo — no se notifica."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=lab-agent: diagnostico/correccion del lab GitOps cruzcloud
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-agent
|
||||
# El manager de systemd --user de devops arranca con HOME=/DATA (el de
|
||||
# /etc/passwd), no /DATA/AppData/home-devops -- ese override solo se
|
||||
# aplica al abrir una shell interactiva via /etc/profile.d/zz-devops-home.sh,
|
||||
# que el manager de systemd nunca carga. Sin esto, claude -p buscaria
|
||||
# ~/.claude/.credentials.json en /DATA/.claude/ (no existe) y fallaria
|
||||
# la autenticacion. Confirmado en vivo 2026-08-26.
|
||||
Environment=HOME=/DATA/AppData/home-devops
|
||||
ExecStart=/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-agent/lab-agent.sh --live
|
||||
TimeoutStartSec=300
|
||||
|
||||
# Limites de recursos (pedido explicito de gobernanza, nunca irrestricto).
|
||||
# El propio script casi siempre corta antes por la pausa nocturna o por
|
||||
# no tener nada elegible, asi que en la practica rara vez se acerca a
|
||||
# estos topes -- estan para contener un caso patologico (claude -p
|
||||
# colgado, kubectl en loop), no para el uso normal.
|
||||
CPUQuota=100%
|
||||
MemoryMax=1G
|
||||
MemorySwapMax=256M
|
||||
Nice=10
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Dispara lab-agent.service cada 60 min, 06:00-23:00 hora local
|
||||
|
||||
[Timer]
|
||||
# Horas 06..22, minuto :00 -> ultima corrida real 22:00 (17 corridas/dia).
|
||||
# El propio script vuelve a chequear ACTIVE_HOURS_START/END al arrancar
|
||||
# (defensa en profundidad si esta unidad se edita a mano sin tocar el
|
||||
# .env, o si corre manualmente fuera de horario).
|
||||
OnCalendar=*-*-* 06..22:00:00
|
||||
AccuracySec=30s
|
||||
Persistent=false
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in new issue
Block a user