Files
takana/scripts/farm/deadman.sh
T
sergioandClaude Opus 4.8 0d35044b53 granja: DEAD-MAN SWITCH — el worker se borra solo tras 1h idle
hworker-4 estuvo 5 DÍAS idle (carga 0.00, sin crontab, sin loops, 37G de KDE
sin cosechar) quemando dinero. El usuario había pedido esto explícitamente
—"los vps se autoapagan cuando se detectan idle por un tiempo"— y de sus 3
puntos (volumen / auto-apagado / cosecha) implementé el 1 y el 3. Éste faltaba.

EL FALLO ERA ESTRUCTURAL, no un olvido: farm-down.sh existe pero es MANUAL. El
modelo "efímero" dependía de que el agente se acordara de llamarlo — y si su
contexto se corta, o la sesión muere, el server queda vivo para siempre. Un
invariante que necesita que alguien recuerde NO es un invariante. Por eso el
switch vive EN EL WORKER: para apagarse no necesita ni al hub ni a mí.

BORRA, no apaga: en Hetzner un server apagado SIGUE COBRANDO (disco + IP). Un
poweroff daría sensación de ahorro y seguiría facturando. Precio: el token vive
en el worker (/etc/hammer-deadman.env 0600). Riesgo real y consciente; se acepta
porque la alternativa MEDIDA fue peor: 5 días de VPS idle.

Cuenta por INACTIVIDAD CONTINUA, no por antigüedad: cualquier señal de trabajo
(hammer build, worker-loop, heartbeat <30min) resetea los ticks. Guarda
/var/lib/hammer-no-borrar aborta el borrado si hay cosecha pendiente.

systemd timer y NO cron, por la lección medida: un cron "validado a mano" nunca
disparó porque el PID 1 de aquella máquina (arje-zero) no tenía crond. Validar
la LÍNEA no es validar que un demonio la ejecute.  y el
farm-up comprueban que el timer quedó ACTIVO — evidencia, no fe.

Cableado en farm-up: todo worker NACE con el switch puesto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:00:24 -04:00

103 lines
5.1 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# deadman.sh — el worker se BORRA SOLO cuando lleva demasiado tiempo sin trabajo.
#
# POR QUÉ EXISTE (2026-07-17): hworker-4 estuvo **5 días idle** (carga 0.00, sin crontab, sin
# loops, 37G de artefactos KDE sin cosechar) quemando dinero. El usuario había pedido esto
# explícitamente — "los vps se autoapagan cuando se detectan idle por un tiempo" — y de los tres
# puntos de su diseño (volumen / auto-apagado / cosecha) se implementaron el 1 y el 3. Éste faltaba.
#
# EL FALLO ERA ESTRUCTURAL, no un olvido: `farm-down.sh` existe pero es MANUAL. El modelo "efímero"
# dependía de que el agente se acordara de llamarlo — y si su contexto se corta, o la sesión muere,
# o simplemente se va, el servidor queda vivo PARA SIEMPRE. Un invariante que depende de que alguien
# recuerde no es un invariante. Por eso esto vive EN EL WORKER: para apagarse no necesita que el hub
# exista, ni que yo esté vivo.
#
# POR QUÉ BORRA Y NO APAGA: en Hetzner un server **apagado sigue cobrando** (mantiene disco e IP).
# `poweroff` daría sensación de ahorro y seguiría facturando. Sólo `hcloud server delete` para el
# reloj. Ese es el precio: el token vive en el worker. Es un riesgo REAL y consciente — un worker
# comprometido puede borrar servers del proyecto. Se acepta porque la alternativa medida fue peor:
# 5 días de VPS idle. Mitigación: el worker es efímero y no guarda secretos propios.
#
# QUÉ CUENTA COMO TRABAJO: un `hammer build` en vuelo, el loop del worker, o un heartbeat fresco
# (`/run/hammer-heartbeat`, que un job largo puede tocar). Si nada de eso aparece durante
# IDLE_MAX_TICKS ticks seguidos, se borra.
#
# LA CUENTA SE REINICIA con cualquier señal de trabajo: no borra por "lleva N horas encendido",
# borra por "lleva N minutos sin hacer NADA".
#
# LECCIÓN APLICADA (el cron que nunca disparó): NO basta validar la lógica a mano. El PID 1 del
# laptop es arje-zero y no tiene crond, así que un cron "validado" jamás corrió. Acá: systemd timer
# (el worker SÍ tiene systemd) + `--verificar` imprime la evidencia de que el timer está armado y
# cuándo disparó por última vez. Si no podés ver que dispara, no está puesto.
set -eu
IDLE_MAX_TICKS="${IDLE_MAX_TICKS:-6}" # 6 ticks × 10 min = 1 h sin trabajo → borrar
ESTADO="/var/lib/hammer-deadman.ticks"
HEARTBEAT="/run/hammer-heartbeat"
HEARTBEAT_MAX_AGE=1800 # 30 min: más viejo que esto no cuenta como vivo
LOG="/var/log/hammer-deadman.log"
log() { echo "$(date -u +%FT%TZ) deadman: $*" >> "$LOG"; }
if [ "${1:-}" = "--verificar" ]; then
echo "══ ¿el dead-man switch está REALMENTE armado?"
echo "── timer:"; systemctl is-active hammer-deadman.timer 2>/dev/null || echo " INACTIVO ⚠"
systemctl list-timers hammer-deadman.timer --no-pager 2>/dev/null | head -3
echo "── ticks idle acumulados: $(cat "$ESTADO" 2>/dev/null || echo 0) / $IDLE_MAX_TICKS"
echo "── últimas líneas del log (evidencia de que DISPARA):"
tail -5 "$LOG" 2>/dev/null | sed 's/^/ /' || echo " (sin log todavía ⇒ NUNCA disparó)"
exit 0
fi
hay_trabajo() {
pgrep -f 'hammer build' >/dev/null 2>&1 && { echo "hammer build en vuelo"; return 0; }
pgrep -f 'farm-worker-loop' >/dev/null 2>&1 && { echo "worker-loop vivo"; return 0; }
if [ -f "$HEARTBEAT" ]; then
edad=$(( $(date +%s) - $(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0) ))
[ "$edad" -lt "$HEARTBEAT_MAX_AGE" ] && { echo "heartbeat fresco (${edad}s)"; return 0; }
fi
return 1
}
if razon=$(hay_trabajo); then
# Trabajo ⇒ la cuenta vuelve a cero. Borrar es por INACTIVIDAD CONTINUA, no por antigüedad.
[ -f "$ESTADO" ] && [ "$(cat "$ESTADO")" != "0" ] && log "trabajo ($razon) ⇒ ticks a 0"
echo 0 > "$ESTADO"
exit 0
fi
ticks=$(( $(cat "$ESTADO" 2>/dev/null || echo 0) + 1 ))
echo "$ticks" > "$ESTADO"
log "sin trabajo: tick $ticks/$IDLE_MAX_TICKS"
[ "$ticks" -lt "$IDLE_MAX_TICKS" ] && exit 0
# ── Punto de no retorno.
self="$(hostname)"
log "IDLE $ticks ticks ⇒ borrando $self"
# GUARDA: nunca borrar con cosecha sin recoger. El worker no sabe qué cosechó el hub, pero sí puede
# negarse a morir si alguien dejó una marca explícita. `farm-run`/la campaña la ponen mientras haya
# artefactos que nadie bajó; el hub la quita al cosechar.
if [ -f /var/lib/hammer-no-borrar ]; then
log "ABORTA: /var/lib/hammer-no-borrar presente (cosecha pendiente) ⇒ sigo vivo, avisá al hub"
exit 0
fi
if [ -z "${HCLOUD_TOKEN:-}" ]; then
log "ERROR: sin HCLOUD_TOKEN ⇒ NO puedo borrarme. Un poweroff NO ahorra (Hetzner cobra igual)."
exit 1
fi
id=$(curl -sS -H "Authorization: Bearer $HCLOUD_TOKEN" \
"https://api.hetzner.cloud/v1/servers?name=$self" 2>/dev/null \
| grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2)
if [ -z "$id" ]; then
log "ERROR: no pude resolver mi propio id de server (¿nombre '$self' no está en el proyecto?)"
exit 1
fi
log "self id=$id ⇒ DELETE"
curl -sS -X DELETE -H "Authorization: Bearer $HCLOUD_TOKEN" \
"https://api.hetzner.cloud/v1/servers/$id" >> "$LOG" 2>&1
log "DELETE enviado; si seguís leyendo esto, falló"