El worker DEBE matarse solo, sin depender de la laptop (es la razón de ser del
dead-man: vive en el worker; el volumen hace que morir no pierda nada). El reaper
del hub queda sólo como último recurso. El dead-man estaba TRIPLEMENTE roto:
1. TOKEN EN EL FICHERO EQUIVOCADO: hay 2 caminos de creación con el token en
lugares distintos (farm-up → /etc/hammer-deadman.env; harkaq-vol → /root/
.hcloud-token). La service lee sólo el primero ⇒ un worker del otro camino
quedaba sin token, disparaba pero salía 1 "sin HCLOUD_TOKEN". Fix: deadman.sh
busca el token EN CADENA (volumen primero, que es lo más persistente).
2. REGEX DEL ID ROTO: la API devuelve JSON con espacio ("id": 126, no "id":126)
y el dead-man usaba '"id":[0-9]+' ⇒ NUNCA resolvía su id, aun con token
válido. Fix: '"id":[[:space:]]*[0-9]+'. (Doblemente roto: por eso NUNCA murió.)
3. FIRING ≠ CAN-DELETE: farm-up sólo verificaba que el timer dispara. Ahora
corre `deadman.sh --puede-borrar` (token en cadena + ve su id en la API) y si
NO puede matarse, DESTRUYE el worker ahí mismo. Un worker que no se autodestruye
es inadmisible ⇒ jamás debe existir.
gioser DOBLE-BLINDADO en TODOS los sitios de borrado (lista negra por nombre +
label role=hammer-worker), igual que farm-down ya tenía: reaper, farm-up-destroy,
deadman, y el helper de harkaq-vol. "Ahí está nuestra vida." Verificado vivo (104d).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
8.9 KiB
Bash
Executable File
153 lines
8.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# cosecha-cron.sh — el LATIDO de la granja: recoger + sembrar, en bucle, sin agente ni tokens.
|
||
#
|
||
# QUÉ HACE cada ciclo, por cada worker vivo en scripts/farm/.fleet:
|
||
# 1. SIEMBRA (sube): rsync de código + recetas laptop→worker (el laptop es canónico; si autoré
|
||
# recetas nuevas —p.ej. openrc— el worker las empieza a moler sin que yo intervenga).
|
||
# 2. RECOGE (baja): rsync del store sellado worker→laptop (CAS ⇒ merge seguro, sin conflicto).
|
||
# 3. Regenera el GRAFO DE ESTADO (build-state.json + build-state-kde.json) desde el store ya
|
||
# cosechado, y commitea SÓLO esos ficheros firmes. El `git log` de docs/state/ ES el avance
|
||
# que el humano ve y sigue (huecos, fallos, qué se selló entre dos ciclos).
|
||
#
|
||
# LO QUE **NO** HACE (a propósito): NO promueve incoming-kde→recipes/ canónico (esa es decisión de
|
||
# clasificación humana — "el hub clasifica", ver granja-promote-colisiones) ni firma el índice. Sólo
|
||
# captura al store del laptop y deja el veredicto para la mañana. Idempotente y re-corrible.
|
||
#
|
||
# INFINITO: vive en el crontab hasta que lo saques (`crontab -e`, borrar la línea cosecha-cron).
|
||
# Robusto a worker-ausente: si el dead-man ya mató al worker (cola seca ⇒ €0), el paso 1/2 falla
|
||
# suave, se loguea y igual regenera+commitea el estado local. NO relanza workers (eso gasta €;
|
||
# es decisión explícita con farm-up).
|
||
#
|
||
# Uso: scripts/farm/cosecha-cron.sh # un ciclo (lo que dispara el cron)
|
||
# */30 * * * * cd /home/sergio/hammer && scripts/farm/cosecha-cron.sh >>work/cosecha-cron.log 2>&1
|
||
# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/hammer), NO_COMMIT=1 (regenera pero no commitea)
|
||
set -uo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||
cd "$ROOT"
|
||
|
||
# LOCK (2026-07-22): el latido ya no viene sólo del crontab — `latido.sh` lo lanza desde la sesión
|
||
# porque el laptop arranca a veces con arje-zero y a veces con OpenRC, y cronie sólo existe en uno
|
||
# de los dos. Con dos disparadores posibles, dos ciclos pueden solaparse: ambos hacen rsync sobre el
|
||
# mismo store y, peor, `git commit`+`push` a la vez (index.lock, o un push que pisa al otro). El lock
|
||
# va ACÁ y no en el llamador para que valga venga de donde venga: cron, latido o a mano.
|
||
# -n = no esperar: si ya hay un ciclo corriendo, este sobra (el próximo tick recoge lo mismo).
|
||
LOCKFILE="${LOCKFILE:-$ROOT/work/.cosecha-cron.lock}"
|
||
mkdir -p "$(dirname "$LOCKFILE")"
|
||
exec 9>"$LOCKFILE"
|
||
if ! flock -n 9; then
|
||
echo "── $(date -u +%FT%TZ) cosecha-cron: ya hay un ciclo en curso ⇒ salgo (no me solapo)"
|
||
exit 0
|
||
fi
|
||
|
||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
|
||
REMOTE="${REMOTE:-/opt/hammer}"
|
||
FLEET="$ROOT/scripts/farm/.fleet"
|
||
HAMMER="${HAMMER:-$ROOT/target/release/hammer}"
|
||
# Workers efímeros reciclan IPs ⇒ no fijar known_hosts (hub-and-spoke, worker sin secretos).
|
||
SSH="ssh -i $SSH_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=15"
|
||
|
||
ts() { date -u +%FT%TZ; }
|
||
echo "── $(ts) cosecha-cron arranca"
|
||
|
||
if [ ! -s "$FLEET" ]; then
|
||
echo " flota vacía (scripts/farm/.fleet) — nada que cosechar este ciclo"
|
||
else
|
||
while read -r name ip; do
|
||
[ -n "${name:-}" ] || continue
|
||
echo "==> $name ($ip)"
|
||
# 1. SIEMBRA: código + recetas laptop→worker (--delete: el laptop manda; nunca toca work/store/target).
|
||
if rsync -az --delete -e "$SSH" \
|
||
--exclude /work --exclude /store --exclude '/store-*' --exclude /target \
|
||
--exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
|
||
--exclude '*.png' --exclude '/content*' \
|
||
./ "root@$ip:$REMOTE/" 2>&1 | tail -2; then
|
||
echo " siembra ✓"
|
||
else
|
||
echo " ⚠ siembra falló (worker ausente/idle-muerto?) — sigo con el siguiente"
|
||
continue
|
||
fi
|
||
# 2. RECOGE: store sellado worker→laptop (CAS, merge seguro).
|
||
if rsync -az --exclude /.dmerge -e "$SSH" "root@$ip:$REMOTE/store/" "$ROOT/store/" 2>&1 | tail -2; then
|
||
echo " cosecha ✓"
|
||
else
|
||
echo " ⚠ cosecha falló — sigo"
|
||
fi
|
||
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
|
||
# LISTA NEGRA (capa 1 de 2, igual que el dead-man): gioser JAMÁS se toca, pase lo que pase.
|
||
# Segunda capa = el chequeo de label abajo. gioser no tiene label Y no matchea worker de la
|
||
# granja ⇒ imposible que muera por acá. "Ahí está nuestra vida" (2026-07-23).
|
||
case "$name" in *gioser*) echo "==> reaper: $name en LISTA NEGRA ⇒ intocable"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue;; esac
|
||
strike_f="$ROOT/work/.reaper/$name"
|
||
# ¿el server siquiera existe? Si ya no está en hcloud (borrado a mano o por un ciclo previo),
|
||
# NO cuesta € y no debe quedar en .fleet para siempre. Se dropea (no entra a `sobreviven`).
|
||
if ! hcloud server describe "$name" -o format='{{.Name}}' >/dev/null 2>&1; then
|
||
echo "==> reaper: $name ya no existe en hcloud ⇒ lo saco de .fleet"; rm -f "$strike_f"; continue
|
||
fi
|
||
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"
|
||
if [ -x "$HAMMER" ]; then
|
||
scripts/build-state.py >/dev/null 2>&1 && echo " build-state.json ✓" || echo " ⚠ build-state.py falló"
|
||
scripts/build-state.py --kde >/dev/null 2>&1 && echo " build-state-kde.json ✓" || echo " ⚠ build-state.py --kde falló"
|
||
# Cola DERIVADA del grafo (P4): el orden de masticado por imagen, en ondas topológicas. No lanza
|
||
# ningún build — sólo deja escrito qué conviene moler primero, para que la cola deje de ser una
|
||
# lista escrita a mano. Barato (lee JSON, no hashea).
|
||
scripts/drenar.py --todos >/dev/null 2>&1 && echo " drenaje.json ✓" || echo " ⚠ drenar.py falló"
|
||
else
|
||
echo " ⚠ sin binario hammer en $HAMMER — no regenero estado"
|
||
fi
|
||
|
||
# 4. Commitear SÓLO el estado firme (el avance que el humano sigue). Recetas nuevas que YO autoré se
|
||
# commitean aparte, a mano, para no meter autoría a medias en un commit de cron.
|
||
if [ "${NO_COMMIT:-}" != "1" ]; then
|
||
git add docs/state/build-state.json docs/state/build-state-kde.json docs/state/drenaje.json 2>/dev/null || true
|
||
if ! git diff --cached --quiet 2>/dev/null; then
|
||
git commit -q -m "estado: cosecha granja $(ts) — avance del árbol KDE" 2>/dev/null \
|
||
&& { git push -q origin main 2>/dev/null && echo "==> estado commiteado+pusheado" \
|
||
|| echo "==> estado commiteado (push falló, reintenta próximo ciclo)"; }
|
||
else
|
||
echo "==> sin cambios de estado este ciclo"
|
||
fi
|
||
fi
|
||
echo "── $(ts) cosecha-cron fin"
|