Files
takana/scripts/farm/farm-worker-loop.sh
T
SergioandClaude Opus 5 b3cc18018c granja: incoming-cosmic e incoming-wlr faltaban en QUEUES del worker
Es el mismo fósil que el propio script documenta para GNOME, repetido con las colas
que nacieron después. Las 25 recetas en deuda de COSMIC no fallaban: nadie las
intentaba, porque su cola no estaba en la lista. Una cola ausente no da error, da
silencio — el mismo modo de fallo que "el corpus no está en las QUEUES del worker".

Se añade la regla en el comentario: cola nueva y línea QUEUES, en el mismo commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
2026-08-29 18:33:35 +00:00

157 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# farm-worker-loop.sh — loop AUTÓNOMO del worker. Construye la cola recipes/incoming/ en
# bucle (PROMOTE=0: sólo sella al store local, NO promueve, NO firma, NO toca gitea), con
# watchdog de disco integrado. El hub (laptop) le mete recetas nuevas vía farm-sync.sh y le
# baja el store sellado; este loop sólo muele lo que haya, 24/7, sin esperar a nadie.
#
# Idempotente: cada ciclo re-escanea incoming/; lo ya-sellado sale por cache-hit (instantáneo),
# sólo construye lo nuevo. Cuando la cola está toda construida, duerme IDLE_SLEEP y reintenta.
#
# Lo lanza systemd (hammer-farm.service). Manual: JOBS=2 ./scripts/farm/farm-worker-loop.sh
set -uo pipefail
HAMMER_DIR="${HAMMER_DIR:-/opt/hammer}"
cd "$HAMMER_DIR"
. "$HOME/.cargo/env" 2>/dev/null || true
# JOBS=1 MIENTRAS EL ADR 0012 ESTÉ SIN DECIDIR (2026-07-22). `build-farm.sh` usa `xargs -P$JOBS`, y
# con >1 dos recetas que compartan dep disparan la carrera del árbol de fuentes: ambas hacen fetch
# del mismo `work/sources/<dep>-<sha>`, una borra mientras la otra usa, y el árbol queda ROTO PARA
# SIEMPRE. Se vio a escala: al invalidar libdrm, 118 de las 205 recetas KDE tuvieron que reconstruir
# de verdad (antes eran cache-hits que ocultaban el problema) y ~93 murieron con
# "/src/.zwrap/cc is not a full path to an existing compiler tool" — el wrapper de zig que la receta
# deja EN EL ÁRBOL DE FUENTE, barrido por el fetch concurrente de otra receta sobre el mismo qtbase.
# El reintento serial de build-farm tampoco las salva, porque el árbol ya quedó inconsistente.
# Serializar es la única mitigación correcta hasta que el ADR 0012 elija (lock por árbol / árbol
# privado / caché inmutable + copia). Subirlo otra vez sin resolver eso reintroduce el bug.
JOBS="${JOBS:-1}"
IDLE_SLEEP="${IDLE_SLEEP:-300}"
# Fichero de lock compartido con campana-deuda.sh (mismo path en los dos, o no hay exclusión).
LOCK_FARM="${LOCK_FARM:-$HAMMER_DIR/work/.farm-build.lock}"
mkdir -p "$HAMMER_DIR/work"
# --- watchdog de disco: cada 3 min borra work/sources/* que ningún bwrap activo bind-monta
# (seguro: sellada=store CAS, en-cola=se re-extrae sola). Evita que el vendoring llene el disco.
# Además, si el disco supera DISK_HIGH%, purga los CACHES Go (GOMODCACHE ~/go/pkg/mod + gocache):
# el `go mod vendor` de las recetas Go acumula ahí decenas de GB sin tope (el vendor/ local de cada
# receta ya tiene lo que el build necesita; el modcache sólo se usa DURANTE el vendor). Si la purga
# pisa un vendor en curso, esa receta reintenta el próximo ciclo (cache-hit el resto). Sin esto, una
# tanda Go grande llena el disco de 80G y el I/O-wait dispara el load (cuello real, no CPU/RAM).
DISK_HIGH="${DISK_HIGH:-82}"
(
while :; do
sleep 180
# Protección 1: árboles que un bwrap activo bind-monta (fase compile dentro del sandbox).
active=$(pgrep -af bwrap 2>/dev/null | grep -oE 'work/sources/[^ ]+' | sort -u)
# Protección 2: árboles cuya receta tiene un `hammer build` EN VUELO. Durante el fetch/`go mod
# vendor`/resolve_phases (host-side, ANTES de que arranque el bwrap) el árbol no está bind-montado
# ⇒ la protección 1 no lo cubre. Una tanda Go con árbol de deps enorme (dnscontrol/lazysql) tarda
# minutos vendoreando; sin esto, el watchdog le borra el go.mod a mitad → "no build system".
building=$(pgrep -af 'release/hammer' 2>/dev/null | grep -oE '[^ ]+\.toml' | sed 's#.*/##;s#\.toml$##' | sort -u)
for d in work/sources/*/; do
[ -d "$d" ] || continue; dd=${d%/}
printf '%s\n' "$active" | grep -qxF "$dd" && continue
nm=$(basename "$dd"); nm=${nm%-*} # work/sources/<name>-<sha40> → <name> (el sha no trae '-')
printf '%s\n' "$building" | grep -qxF "$nm" && continue
# Protección 3: árbol CALIENTE (modificado hace <2 min) ⇒ se está extrayendo AHORA. Una dep
# transitiva (p.ej. cairo bajo pango/gtk4) se extrae host-side ANTES del bwrap y con un nombre
# que NO coincide con la receta en vuelo ⇒ protecciones 1 y 2 no la cubren. Sin esto, el rm -rf
# compite con el tar y deja el árbol a medias ("Cannot mkdir" / luego "Directory not empty" para
# siempre). Un árbol en frío (extracción ya fallida/terminada) queda purgable ⇒ se re-extrae limpio.
[ -n "$(find "$dd" -mmin -2 -print -quit 2>/dev/null)" ] && continue
rm -rf "$dd"
done
use=$(df --output=pcent "$HAMMER_DIR" 2>/dev/null | tr -dc '0-9')
if [ -n "$use" ] && [ "$use" -ge "$DISK_HIGH" ]; then
# OJO: `go clean -modcache`/`-cache` borran ~/go/pkg/mod y ~/.cache/go-build ENTEROS. Ambos los
# usa `go mod vendor` (modcache p/ módulos, go-build p/ verificar el grafo). Si la purga corre
# mientras un vendor está activo —o en la VENTANA justo antes de que arranque (otra receta de la
# cola en serie)— le arranca el cache bajo los pies (`.partial`/`go-build/...: no such file`) y
# ESE vendor falla. La purga de work/sources de arriba ya liberó lo grueso (segura: saltea las
# protegidas). Por eso NO purgo las caches Go si hay CUALQUIER `hammer build` en vuelo (no solo
# un `go mod vendor` visible en este instante): difiero al próximo tick / al gap idle entre
# ciclos. El sandbox `go install` usa GOCACHE=/src + -mod=vendor ⇒ no toca estas caches del host.
if pgrep -f 'go mod vendor' >/dev/null 2>&1 || pgrep -f 'release/hammer.* build ' >/dev/null 2>&1; then
echo "$(date -u +%FT%TZ) watchdog: disco ${use}% ≥ ${DISK_HIGH}% pero hay build en vuelo ⇒ difiero purga caches Go"
else
echo "$(date -u +%FT%TZ) watchdog: disco ${use}% ≥ ${DISK_HIGH}% ⇒ purgo caches Go"
go clean -modcache 2>/dev/null; go clean -cache 2>/dev/null
fi
fi
done
) &
# Colas a construir (sólo SELLA, PROMOTE=0; el hub promueve+firma). `recipes/incoming` es la cola de
# staging general; `recipes/incoming-go` es la AISLADA del frente Go (pre-sembrada para moler 24/7).
# build-farm hace `rm -rf work/farm` al inicio ⇒ construirlas EN SERIE (una por vuelta) es seguro,
# sin colisión. El glob es TOP-LEVEL (`$Q/*.toml`), así que `recipes/incoming/.deferred/` (24 recetas
# aparcadas con diagnóstico, p.ej. git con muro en libgit.a) NO se muele — es un subdir a propósito.
# incoming-clib: libs C (toolkit/foundational) que el worker SELLA pero el cron de cosecha-go IGNORA
# (son build-deps, se usan vía catálogo; no tienen binario que el gate Go pueda smoke-testear).
#
# ⚠ EL ORDEN IMPORTA Y LAS COLAS GNOME VAN ANTES DE KDE (2026-08-06). Este default se escribió cuando
# `incoming-gnome-onda1` era el frente GNOME entero, y quedó fósil: faltaban `incoming-gnome` (86
# recetas, donde viven gtk4/mutter/gnome-shell) y `incoming-gnome-onda2` (la isla dinámica). O sea que
# el worker decía moler GNOME y molía 6 recetas de 114. Además iban DESPUÉS de las 205 de KDE, así que
# aunque hubieran estado listadas, un ciclo con la cola KDE llena las dejaba sin turno.
#
# ⚠ EL MISMO FÓSIL, OTRA VEZ (2026-08-29). El párrafo de arriba se escribió por GNOME y la lección no
# se aplicó a las colas que nacieron después: `incoming-cosmic` (47 recetas) e `incoming-wlr` (15)
# NUNCA estuvieron acá. El worker decía moler la granja y no tocaba ni una de las 25 recetas en deuda
# de COSMIC — no fallaban, es que nadie las intentaba. Es el mismo modo de fallo que "el corpus no
# está en las QUEUES del worker": una cola ausente no da error, da silencio.
# REGLA: crear una cola `recipes/incoming-*` y no añadirla acá es dejarla muerta. Si aparece una
# nueva, va en esta línea EN EL MISMO COMMIT.
# Orden: las colas con deuda van ANTES de las cerradas (kde 163/163, wlr 129/129 salen por cache-hit).
QUEUES="${QUEUES:-recipes/incoming recipes/incoming-go recipes/incoming-clib recipes/incoming-gnome-onda1 recipes/incoming-gnome-onda2 recipes/incoming-gnome recipes/incoming-cosmic recipes/incoming-kde recipes/incoming-wlr}"
# REBUILD DEL BINARIO AL ARRANCAR (2026-07-18): el hammer horneado en la imagen golden ENVEJECE.
# Caso real que costó una noche de KDE: la golden del 29-jun traía un hammer sin el fix del overlay
# lowerdir (commit e69eaaa, 12-jul) ⇒ toda receta de muchas deps (kio=52, kwin, plasma-*) desbordaba
# el límite de 4KB del mount options y el sandbox NI ARRANCABA — 43 recetas atascadas jurando "en
# cola". El source SÍ llega fresco (farm-sync rsync-ea el repo), pero nadie recompilaba. Reconstruir
# aquí garantiza que el worker corre el CÓDIGO VIGENTE, no el fósil de la imagen. Cacheado: ~24s si
# no cambió nada, minutos en frío. Si cargo falla, seguimos con el binario que haya (mejor que abortar).
if command -v cargo >/dev/null 2>&1; then
echo "$(date -u +%FT%TZ) rebuild hammer desde el source rsync-eado (evita el fósil de la golden)…"
cargo build --release --bin hammer -j"$JOBS" 2>&1 | tail -2 || echo " ⚠ rebuild falló — uso el binario existente"
fi
echo "$(date -u +%FT%TZ) worker-loop arrancado: JOBS=$JOBS IDLE_SLEEP=$IDLE_SLEEP QUEUES='$QUEUES'"
while :; do
total=0
for Q in $QUEUES; do
n=$(ls "$Q"/*.toml 2>/dev/null | wc -l)
[ "$n" -gt 0 ] || continue
total=$((total + n))
echo "$(date -u +%FT%TZ) ciclo: $n recetas en $Q"
# LOCK COMPARTIDO CON campana-deuda.sh (2026-07-22): ambos construyen sobre el mismo work/, y
# `fetch` nombra el árbol de fuentes de forma determinista (`work/sources/<receta>-<sha16>`) ⇒
# dos procesos que construyan la misma DEP apuntan al mismo directorio y uno hace `remove_dir_all`
# mientras el otro corre `tar -x`, dejándolo a medias con "Directory not empty" (os error 39) para
# siempre. Se toma POR COLA y se suelta al terminarla, en vez de por bucle entero. Más fino no
# se puede sin romper el `xargs -P2` de build-farm.sh.
# OJO con la expectativa: `flock` NO es FIFO. Como el loop vuelve a pedirlo enseguida, suele
# ganarle la carrera a la campaña que espera; medido, la campaña entra al terminar TODAS las
# colas, no entre dos. La ventana real es el IDLE_SLEEP. Por eso la campaña espera con techo
# (LOCK_WAIT) y sale limpia si no entra, en vez de colgarse.
# NO cubre el `-P2` interno: dos recetas de la MISMA cola que compartan dep siguen pudiendo
# pisarse. Ese es el arreglo de fondo, un lock por árbol dentro de `fetch`.
# El `flock -n` primero es sólo para poder DECIR que estamos esperando: si el log dijera
# "ciclo: N recetas" y después nada, parecería que el loop trabaja cuando en realidad está
# bloqueado, y ese malentendido es exactamente el que cuesta horas de diagnóstico.
(
if ! flock -n 9; then
echo "$(date -u +%FT%TZ) esperando el lock de build (campana-deuda lo tiene)…"
flock 9
echo "$(date -u +%FT%TZ) lock tomado, sigo con $Q"
fi
JOBS="$JOBS" PROMOTE=0 QUEUE="$Q" scripts/build-farm.sh 2>&1 | tail -50
) 9>"$LOCK_FARM"
done
if [ "$total" -gt 0 ]; then
echo "$(date -u +%FT%TZ) ciclo terminado; store sellado disponible para el hub"
else
echo "$(date -u +%FT%TZ) colas vacías; durmiendo ${IDLE_SLEEP}s"
fi
sleep "$IDLE_SLEEP"
done