granja: reaper HUB-SIDE — la garantía "vps idle inadmisible" deja de depender del worker
INCIDENTE (2026-07-23): un worker quedó 3.5h idle quemando € mientras el usuario
dormía. El dead-man del worker estaba "activo" (timer disparando cada 10 min)
pero FALLABA con exit 1 en cada tick: llegaba a matarse pero su
/etc/hammer-deadman.env no tenía token válido ⇒ salía 1 "sin HCLOUD_TOKEN" y
nunca se borraba. Verificaron que el timer DISPARA, no que puede BORRAR —
firing ≠ can-delete.
Causa de fondo: la garantía dependía de que CADA worker se auto-provisione bien
un token, y eso falla EN SILENCIO. Un invariante que depende de una provisión
frágil no es un invariante.
FIX en dos capas (defensa en profundidad):
1. REAPER HUB-SIDE (cosecha-cron.sh): el hub tiene un token que funciona y el
latido corre cada 30 min aunque no haya sesión (setsid). Un worker sin trabajo
activo REAPER_MAX=2 ciclos (~1h) se BORRA desde el hub, con el MISMO blindaje
de label role=hammer-worker (gioser jamás pasa). Cubre "dead-man del worker
roto". El dead-man del worker sigue cubriendo "hub caído".
2. farm-up verifica CAN-DELETE, no sólo firing: que el token del worker vea su
propio id en la API (lo mismo que hace al morir). Si no puede, LO GRITA al
provisionar en vez de descubrirlo 3.5h tarde.
Acción inmediata tomada: worker borrado a mano (label verificado), €0 baseline
restaurado — sólo queda gioser (protegido, fijo). Todo estaba cosechado.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semantic delta
No definition changed: this change is text, documentation or data.
2 files · +48 −0
- scripts/farm/cosecha-cron.sh +37 −0
- scripts/farm/farm-up.sh +11 −0
diff --git a/scripts/farm/cosecha-cron.sh b/scripts/farm/cosecha-cron.sh
index a1312c6a..16a1a95f 100755
--- a/scripts/farm/cosecha-cron.sh
+++ b/scripts/farm/cosecha-cron.sh
@@ -76,6 +76,43 @@ else
done < "$FLEET"
fi
+# 2.5 REAPER HUB-SIDE (2026-07-23). El dead-man DEL WORKER probó FRÁGIL: un worker quedó 3.5h idle
+# quemando € porque su `/etc/hammer-deadman.env` no tenía token válido ⇒ el dead-man llegaba a
+# matarse pero salía 1 "sin HCLOUD_TOKEN" cada tick. La garantía "un vps idle es inadmisible" NO
+# puede depender de que cada worker se auto-provisione bien un token (falla en silencio). El HUB sí
+# tiene un token que funciona, y este latido corre cada 30 min aunque no haya sesión (setsid) ⇒ el
+# hub es la AUTORIDAD de vida del worker. Es defensa en profundidad: el dead-man del worker cubre
+# "hub caído"; esto cubre "dead-man del worker roto". Un worker sin trabajo activo REAPER_MAX ciclos
+# seguidos se BORRA desde acá, con el MISMO blindaje que el dead-man (label role=hammer-worker;
+# gioser no tiene label ⇒ jamás pasa, ni por accidente).
+REAPER_MAX="${REAPER_MAX:-2}" # 2 ciclos × 30 min ≈ 1 h idle, igual que el dead-man del worker
+if [ -s "$FLEET" ] && command -v hcloud >/dev/null 2>&1; then
+ mkdir -p "$ROOT/work/.reaper"; sobreviven=""
+ while read -r name ip; do
+ [ -n "${name:-}" ] || continue
+ strike_f="$ROOT/work/.reaper/$name"
+ if $SSH root@"$ip" 'pgrep -f "hammer build|campana-deuda|farm-worker-loop" >/dev/null 2>&1'; then
+ echo 0 > "$strike_f"; sobreviven="${sobreviven}${name} ${ip}
+"; continue
+ fi
+ strikes=$(( $(cat "$strike_f" 2>/dev/null || echo 0) + 1 )); echo "$strikes" > "$strike_f"
+ echo "==> reaper: $name SIN trabajo activo — strike $strikes/$REAPER_MAX"
+ if [ "$strikes" -lt "$REAPER_MAX" ]; then sobreviven="${sobreviven}${name} ${ip}
+"; continue; fi
+ # BLINDAJE (igual que el dead-man): sólo se borra un server con label role=hammer-worker.
+ if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
+ echo " ☠ IDLE $strikes ciclos ⇒ el HUB borra $name (label verificado; gioser protegido)"
+ if hcloud server delete "$name" >/dev/null 2>&1; then rm -f "$strike_f"
+ else echo " ⚠ delete falló — sobrevive, reintenta próximo ciclo"; sobreviven="${sobreviven}${name} ${ip}
+"; fi
+ else
+ echo " ⛔ $name SIN label hammer-worker ⇒ NO se borra (protegido)"; sobreviven="${sobreviven}${name} ${ip}
+"
+ fi
+ done < "$FLEET"
+ printf '%s' "$sobreviven" | grep -v '^[[:space:]]*$' > "$FLEET.tmp" 2>/dev/null; mv "$FLEET.tmp" "$FLEET" 2>/dev/null || true
+fi
+
# 3. Regenerar el grafo de estado desde el store ya cosechado (usa el hammer del laptop, que tiene
# el subcomando `hash`). Barato (~14s cada uno). El corpus canónico y el frente KDE, por separado.
echo "==> regenerando grafo de estado"
diff --git a/scripts/farm/farm-up.sh b/scripts/farm/farm-up.sh
index 72099e95..85938f54 100755
--- a/scripts/farm/farm-up.sh
+++ b/scripts/farm/farm-up.sh
@@ -141,6 +141,17 @@ for k in $(seq 1 "$N"); do
# estaba "validado" y nunca disparó porque el PID 1 no tenía crond).
if $SSH root@"$ip" 'systemctl is-active hammer-deadman.timer' 2>/dev/null | grep -q active; then
echo " ✓ dead-man armado: $($SSH root@"$ip" 'systemctl list-timers hammer-deadman.timer --no-pager 2>/dev/null | sed -n 2p' | awk '{print $1, $2, $3}')"
+ # FIRING ≠ CAN-DELETE (2026-07-23): un worker quedó idle 3.5h quemando € porque el token en
+ # /etc/hammer-deadman.env estaba vacío ⇒ el dead-man DISPARABA pero salía 1 "sin HCLOUD_TOKEN"
+ # y nunca se borraba. Que el timer esté activo NO prueba que pueda matarse. Se verifica que su
+ # token VE su propio id en la API — lo MISMO que hace al morir.
+ if $SSH root@"$ip" '. /etc/hammer-deadman.env 2>/dev/null; [ -n "${HCLOUD_TOKEN:-}" ] && curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" "https://api.hetzner.cloud/v1/servers?name=$(hostname)" 2>/dev/null | grep -q "\"id\""' 2>/dev/null; then
+ echo " ✓ dead-man PUEDE borrarse (token válido, se ve en la API)"
+ else
+ echo " ⛔ dead-man NO PUEDE borrarse: token vacío/inválido en /etc/hammer-deadman.env."
+ echo " Solo, este worker quedaría idle PARA SIEMPRE. Lo cubre el reaper del hub"
+ echo " (cosecha-cron), pero sólo si el latido corre. Arreglar la provisión del token."
+ fi
else
echo " ⚠ el timer NO quedó activo ⇒ este worker puede quedarse idle para siempre. Revisar."
fi