Va solo y ANTES de cambiar ninguna invocación, a propósito. El worker compila desde fuente (farm-worker-loop.sh) y la siembra excluye /target: si primero cambiara las llamadas a `takana` y después el build, habría una ventana en la que el worker sincroniza scripts nuevos y sigue teniendo sólo el binario viejo — y eso no falla ruidosamente, deja de cosechar en silencio. Con los dos emitidos, cualquier orden de sincronización queda sano.
247 lines
18 KiB
Bash
Executable File
247 lines
18 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}"
|
|
# Cuántos minutos se le respetan a un árbol EN FRÍO cuando el disco NO está apretado. Es la ventana
|
|
# de post-mortem: por debajo de esto, un fallo se lleva su propia evidencia (ver protección 4).
|
|
SUELO_FRIO_MIN="${SUELO_FRIO_MIN:-120}"
|
|
(
|
|
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.
|
|
# Protección 4: EL POST-MORTEM (2026-09-06). Con el suelo en 2 min, el árbol de una receta que
|
|
# ACABA DE FALLAR se barre antes de que nadie lo mire: `waterfox` murió a las 03:20:15 tras 36
|
|
# min y 23.980 pasos, enlazando `libxul.so`, y a las 03:22 `work/sources/` estaba VACÍO. El
|
|
# objdir con la evidencia se fue con él y reproducirlo cuesta otros 36 min.
|
|
#
|
|
# El repo ya tiene la doctrina escrita en otros dos sitios y este watchdog la contradecía:
|
|
# `campana-deuda.sh` dice «NO se poda tras un ✗: el árbol de una receta que falló es el
|
|
# post-mortem», y `poda-fuentes.sh` usa un suelo de 24 h justamente para «dejar el post-mortem
|
|
# del día».
|
|
#
|
|
# No se arregla subiendo el suelo a secas: este watchdog existe para que una tanda Go no llene
|
|
# el disco vendoreando, y ahí los minutos importan. Se arregla siendo agresivo SÓLO cuando el
|
|
# recurso escasea de verdad — con disco holgado, un árbol frío no le hace daño a nadie.
|
|
usado=$(df --output=pcent "$HAMMER_DIR" 2>/dev/null | tr -dc '0-9') # pcent = USADO, no libre
|
|
suelo=$SUELO_FRIO_MIN
|
|
[ -n "$usado" ] && [ "$usado" -ge "$DISK_HIGH" ] && suelo=2 # disco apretado ⇒ como antes
|
|
[ -n "$(find "$dd" -mmin -"$suelo" -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/` NO se
|
|
# muele — es un subdir a propósito.
|
|
#
|
|
# ⚠ QUÉ HAY REALMENTE AHÍ (revisado el 2026-09-01, y no era lo que decía este comentario). Eran 24 y
|
|
# quedan 19: se retiraron `dprint`, `git`, `gmp`, `mise` y `yj` porque **ya se habían resuelto en
|
|
# otro fichero y sellan** (`recipes/git.toml`, `recipes/mise.toml`, `incoming-kde/gmp.toml`…). Su
|
|
# copia aparcada era la versión ANTERIOR y seguía leyéndose como deuda abierta. El ejemplo que este
|
|
# comentario daba —«git con muro en libgit.a»— era justamente uno de esos cinco.
|
|
#
|
|
# Y no son «aparcadas con diagnóstico»: las 19 que quedan son **volcado CRUDO del importador**, 15
|
|
# de `import-nix` y 4 de `import-alpine`, todas con la misma cabecera «PUNTO DE PARTIDA, no final».
|
|
# Nadie las intentó todavía; no hay ningún muro documentado que respetar. ⇒ Es una cola de trabajo
|
|
# sin empezar, no una lista de imposibles, y conviene no leerla como lo segundo.
|
|
# `incoming-clib` SE RETIRÓ el 2026-09-01: era la cola de staging de las tandas base-system/foot, y
|
|
# sus recetas ya se habían PROMOVIDO a canónicas (`recipes/`) commit a commit —«promover 7 tier-2 a
|
|
# canónicas», «promover cadena crypto», «promover tllist/utf8proc/scdoc/fcft/foot»—. Lo que quedaba
|
|
# eran 0 recetas y 26 PARCHES sueltos. Y eran inalcanzables por construcción, no por descuido:
|
|
# `fetch::apply_patches` resuelve `recipe.base_dir.join(nombre)` y `base_dir` es el directorio de la
|
|
# receta, SIN fallback a `recipes/` ⇒ un parche en una cola sin recetas no lo puede pedir nadie.
|
|
# ⚠ Corolario para cuando se promueva una cola: los `.patch` NO viajan con la receta. Promover deja
|
|
# la cola en un estado que parece vivo (tiene ficheros) y no muele nada.
|
|
#
|
|
# `incoming-go` NO existe como directorio y se queda en la lista A PROPÓSITO: es la cola aislada del
|
|
# frente Go, y el guard `ls "$Q"/*.toml` salta las colas vacías. Quitarla sería el error que los dos
|
|
# párrafos de abajo documentan, pero al revés: el día que ese frente la recree, nadie la molería.
|
|
#
|
|
# ⚠ 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. Y la simétrica: si una cola se RETIRA, sale de acá en
|
|
# el mismo commit, o el worker muele un glob vacío para siempre sin que nada lo diga.
|
|
#
|
|
# `incoming-gnome-onda1` SE RETIRÓ el 2026-09-01, y el motivo cierra el arco de los dos párrafos de
|
|
# arriba: sus 6 recetas eran 4 duplicados byte a byte de `incoming-gnome` —mismo ArtifactHash, o sea
|
|
# que sellaban en la MISMA dirección y entraban por cache-hit—, más `gnome-desktop` sin
|
|
# `xkeyboard-config` en `[deps]`, más un `gsettings-desktop-schemas` con `introspection=false`, que
|
|
# es la versión ANTERIOR al 2026-07-28 (mutter necesita el `GDesktopEnums-3.0.gir` que sale de ahí).
|
|
# Al jubilar `gnome-desktop` las dos hojas de la cola se quedaron sin consumidor: molía para nadie.
|
|
# ⚠ Lo caro no era el tiempo —todo salía por caché— sino que el `gsettings-desktop-schemas` viejo
|
|
# competía por el NOMBRE con el bueno: dos artefactos homónimos con hash distinto en el store.
|
|
#
|
|
# `incoming-gnome-onda2` —«la isla dinámica» del párrafo de arriba— se retiró el mismo día, y es el
|
|
# caso LIMPIO del mismo fenómeno: sus 22 recetas eran idénticas a las de `incoming-gnome` y las 22
|
|
# con el MISMO ArtifactHash, o sea la misma dirección del store. Sin divergencias, sin colisión de
|
|
# nombre y sin un solo artefacto que podar al retirarla. El andamio ya no sostenía nada: el trabajo
|
|
# de la isla dinámica vive en `incoming-gnome`, que es la cola que el perfil usa (119/119).
|
|
# ⇒ La comprobación que decide NO es `diff` de las recetas sino `hammer hash`: la resolución de deps
|
|
# es hermano→padre, así que dos ficheros idénticos en colas distintas PUEDEN sellar distinto.
|
|
# 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-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 takana --bin hammer -j"$JOBS" 2>&1 | tail -2 || echo " ⚠ rebuild falló — uso el binario existente"
|
|
fi
|
|
|
|
# EL MISMO REBUILD, PERO POR CICLO Y NO SÓLO AL ARRANCAR (2026-09-06). El bloque de arriba corre
|
|
# UNA vez, y este loop vive días: `uptime` de 8 días con el binario congelado en el arranque. El
|
|
# source SÍ sigue llegando fresco cada 30 min (la siembra rsync-ea el repo), así que el worker
|
|
# termina con FUENTE NUEVO y BINARIO VIEJO — que es peor que el fósil de la golden, porque nada lo
|
|
# delata: el ArtifactHash NO se mueve por la versión de hammer, así que el build se comporta
|
|
# distinto y `build-state.json` sigue en verde.
|
|
#
|
|
# Caso real que lo motiva: el 2026-09-06 el worker corría un hammer de las 04:54 y el arreglo que
|
|
# materializa los submódulos git había entrado a las 15:11 (`114aeba`). `waterfox` moría en 4 s por
|
|
# `waterfox/browser/locales/moz.build` inexistente y el diagnóstico apuntaba a la receta, a los
|
|
# parches y al `--filter=blob:none` — a todo menos al binario. `strings` sobre los dos binarios lo
|
|
# resolvió en un minuto: hub 2 cadenas de `.gitmodules`, worker 0.
|
|
#
|
|
# Se comprueba por MTIME y no se recompila a ciegas: `cargo` cacheado son ~24 s, pero correrlo cada
|
|
# IDLE_SLEEP sin motivo es ruido en el log y CPU que le estamos quitando a un build. Si nada cambió,
|
|
# esto cuesta un `find`.
|
|
rebuild_si_hace_falta() {
|
|
command -v cargo >/dev/null 2>&1 || return 0
|
|
bin=target/release/hammer
|
|
[ -x "$bin" ] || { echo "$(date -u +%FT%TZ) no hay binario: compilando"; cargo build --release --bin takana --bin hammer -j"$JOBS" 2>&1 | tail -2; return 0; }
|
|
# ¿Algún fuente más nuevo que el binario? (Cargo.lock incluido: un bump de dep también cuenta.)
|
|
if [ -n "$(find crates Cargo.toml Cargo.lock -newer "$bin" -type f -print -quit 2>/dev/null)" ]; then
|
|
echo "$(date -u +%FT%TZ) el source es más nuevo que el binario ⇒ recompilo hammer"
|
|
cargo build --release --bin takana --bin hammer -j"$JOBS" 2>&1 | tail -2 || echo " ⚠ rebuild falló — sigo con el binario existente"
|
|
fi
|
|
}
|
|
|
|
echo "$(date -u +%FT%TZ) worker-loop arrancado: JOBS=$JOBS IDLE_SLEEP=$IDLE_SLEEP QUEUES='$QUEUES'"
|
|
while :; do
|
|
rebuild_si_hace_falta
|
|
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
|
|
# `9>&-` cierra el fd DEL LOCK en el hijo: si un build deja un proceso fugado, sin esto el
|
|
# nieto retiene el lock para siempre y la granja queda muda (medido 2026-09-08; ver la
|
|
# regla 1 de CLAUDE.md). Acá el lock vive en el subshell `( … ) 9>`, así que alcanza con
|
|
# cerrarlo en los hijos y no hace falta re-ejecutar nada.
|
|
JOBS="$JOBS" PROMOTE=0 QUEUE="$Q" scripts/build-farm.sh 2>&1 9>&- | tail -50 9>&-
|
|
) 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
|