El bloque de rebuild que ya existía corre una sola vez, al arrancar el loop, y
este loop vive días — hoy el worker llevaba 8 de uptime. El source SÍ sigue
llegando fresco cada 30 min (la siembra rsync-ea el repo), así que el worker
acaba con FUENTE NUEVO y BINARIO VIEJO.
Eso es peor que el fósil de la golden que motivó el bloque original, porque nada
lo delata: la versión de hammer NO entra en el ArtifactHash, así que el build se
comporta distinto y build-state.json sigue en verde. Misma familia que el lab
fuera de hash_inputs, sin siquiera el aviso del lock.
Caso real que lo motiva, de hoy: 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 CUATRO SEGUNDOS por waterfox/browser/locales/moz.build
inexistente, y el diagnóstico apuntaba a la receta, a los once parches de musl y
al --filter=blob:none: a todo menos al binario. Lo resolvió `strings` sobre los
dos binarios en un minuto — hub 2 cadenas de .gitmodules/gitlink, worker 0.
Recompilado el worker (23 s) y borrado el árbol de fuentes viejo, waterfox pasa
el punto donde moría y el submódulo se materializa.
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ó cuesta un `find`. Probado en los dos
sentidos (fuente más nuevo -> detecta; binario más nuevo -> no hace nada).
224 lines
16 KiB
Bash
Executable File
224 lines
16 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/` 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 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 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 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
|
|
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
|