Files
hammer/scripts/farm/cosecha-cron.sh
T
sergioandClaude Opus 4.8 3de75a5229 granja: el dead-man del worker se mata SOLO (sin hub) — 3 bugs + gioser doble-blindado
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>
2026-07-23 03:23:42 -04:00

153 lines
8.9 KiB
Bash
Executable File
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.
#!/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"