gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1471Shell 173Rust 105Markdown 943545 Sutichaykuna

⎇ main scripts/farm/farm-worker-loop.sh Shell · 342 siq'ikuna · 24.7 KB Ñawpa willay ch'uya

fix(granja): el límite de fds vivía en UN llamador, y la campaña manual no lo tenía 24fa7ec83 · Sergio ·

1#!/usr/bin/env bash
2# farm-worker-loop.sh — loop AUTÓNOMO del worker. Construye la cola recipes/incoming/ en
3# bucle (PROMOTE=0: sólo sella al store local, NO promueve, NO firma, NO toca gitea), con
4# watchdog de disco integrado. El hub (laptop) le mete recetas nuevas vía farm-sync.sh y le
5# baja el store sellado; este loop sólo muele lo que haya, 24/7, sin esperar a nadie.
6#
7# Idempotente: cada ciclo re-escanea incoming/; lo ya-sellado sale por cache-hit (instantáneo),
8# sólo construye lo nuevo. Cuando la cola está toda construida, duerme IDLE_SLEEP y reintenta.
9#
10# Lo lanza systemd (takana-farm.service). Manual:  JOBS=2 ./scripts/farm/farm-worker-loop.sh
11set -uo pipefail
12HAMMER_DIR="${TAKANA_DIR:-${HAMMER_DIR:-/opt/takana}}"
13cd "$HAMMER_DIR"
14. "$HOME/.cargo/env" 2>/dev/null || true
15# JOBS=1 MIENTRAS EL ADR 0012 ESTÉ SIN DECIDIR (2026-07-22). `build-farm.sh` usa `xargs -P$JOBS`, y
16# con >1 dos recetas que compartan dep disparan la carrera del árbol de fuentes: ambas hacen fetch
17# del mismo `work/sources/<dep>-<sha>`, una borra mientras la otra usa, y el árbol queda ROTO PARA
18# SIEMPRE. Se vio a escala: al invalidar libdrm, 118 de las 205 recetas KDE tuvieron que reconstruir
19# de verdad (antes eran cache-hits que ocultaban el problema) y ~93 murieron con
20# "/src/.zwrap/cc is not a full path to an existing compiler tool" — el wrapper de zig que la receta
21# deja EN EL ÁRBOL DE FUENTE, barrido por el fetch concurrente de otra receta sobre el mismo qtbase.
22# El reintento serial de build-farm tampoco las salva, porque el árbol ya quedó inconsistente.
23# Serializar es la única mitigación correcta hasta que el ADR 0012 elija (lock por árbol / árbol
24# privado / caché inmutable + copia). Subirlo otra vez sin resolver eso reintroduce el bug.
25# ── ⚠ EL LÍMITE DE DESCRIPTORES, QUE SE LEE COMO RECETA ROTA (2026-10-11) ────────────────────
26# Medido en el worker: `obs-studio` y `spectacle` fallaban las dos con
27#
28#     error: failed to open object libavcodec/x86/rv34dsp_init.o: ProcessFdQuotaExceeded
29#     make: *** [ffbuild/library.mak:119: libavcodec/libavcodec.so.61] Error 1
30#
31# y no es un fallo de ffmpeg: **libavcodec tiene 1.093 ficheros `.o` y el límite BLANDO era 1024**.
32# El enlazador los abre a la vez y se pasa por 69. El límite DURO es 524288, así que subir el blando
33# no necesita privilegios — nadie lo había subido porque el default de 1024 alcanza para todo lo
34# demás del corpus.
35#
36# Es el tercer miembro de la misma familia, y conviene verlo junto: **bytes** (disco lleno ⇒ ✗ en
37# recetas sanas), **inodos** (`df -h` al 90 % y cero inodos ⇒ «0 fallan» mentiroso) y ahora **fds**.
38# Los tres son límites de la MÁQUINA que el informe presenta como defecto de la RECETA, y los tres
39# cuestan el mismo rato: alguien lee `obs-studio.toml` buscando un bug que no está ahí.
40#
41# Se sube acá y no en la unidad de systemd porque este guion VIAJA con el código: una unidad que
42# vive sólo en el worker se pierde el día que la caja se rehaga, y eso ya pasó con otras cosas.
43# ⇒ MOVIDO a `scripts/lib/subir-fds.sh` el 2026-10-19, porque la copia vivía sólo acá y
44# `campana-deuda.sh` —el camino de las campañas manuales— no la tenía: una tanda a mano perdió dos
45# recetas por el mismo límite. La lógica es idéntica; lo que cambia es que ahora hay UN sitio.
46. "$(cd "$(dirname "$0")/../lib" && pwd)/subir-fds.sh"
47subir_fds
48
49JOBS="${JOBS:-1}"
50IDLE_SLEEP="${IDLE_SLEEP:-300}"
51# Fichero de lock compartido con campana-deuda.sh (mismo path en los dos, o no hay exclusión).
52LOCK_FARM="${LOCK_FARM:-$HAMMER_DIR/work/.farm-build.lock}"
53mkdir -p "$HAMMER_DIR/work"
54
55# --- watchdog de disco: cada 3 min borra work/sources/* que ningún bwrap activo bind-monta
56# (seguro: sellada=store CAS, en-cola=se re-extrae sola). Evita que el vendoring llene el disco.
57# Además, si la presión (bytes o INODOS) supera DISK_HIGH%, purga los CACHES Go (GOMODCACHE
58# ~/go/pkg/mod + gocache) **y el cache global de zig** (`.dev-fs/cache/zig`), que es el más gordo de
59# los tres y el que nadie podaba: 11,4 M de inodos de 12,5 M usados, medido el 2026-10-07.
60# el `go mod vendor` de las recetas Go acumula ahí decenas de GB sin tope (el vendor/ local de cada
61# receta ya tiene lo que el build necesita; el modcache sólo se usa DURANTE el vendor). Si la purga
62# pisa un vendor en curso, esa receta reintenta el próximo ciclo (cache-hit el resto). Sin esto, una
63# tanda Go grande llena el disco de 80G y el I/O-wait dispara el load (cuello real, no CPU/RAM).
64DISK_HIGH="${DISK_HIGH:-82}"
65# ⚠ Y EL MISMO UMBRAL PARA LOS INODOS, porque lo que tumbó esta caja no fueron los bytes
66# (2026-10-07, medido): `df -h` decía **90% con 20 G libres** —se lee como «va bien»— y `df -i`
67# decía **13107200/13107200, 0 libres**. El journal traía **63.000 `No space left on device` desde
68# el 28-sep**: el worker no podía escribir ni sus propios logs, y estuvo así nueve días.
69#
70# Peor que el fallo: el loop informaba «BUILD-YIELD: 37/57 construyen (0 fallan)». Las 20 que no
71# construyen no se cuentan como fallo **porque no puede escribir el `.fail` que las marcaría** — el
72# mecanismo que registra el fallo lo rompe la misma causa que el fallo, así que el síntoma es un
73# **✓ FALSO**: la granja «va bien» durante nueve días.
74#
75# Este watchdog estaba mirando el medidor equivocado: purgó cachés Go puntualmente, por el % de
76# BYTES, con los inodos a cero. No es que el umbral envejeciera: vigilaba una magnitud distinta de
77# la que falla. Ahora la presión es el MÁXIMO de las dos, y el log dice cuál disparó — si no lo
78# dijera, un día alguien purgaría cachés buscando bytes que no faltan.
79INODE_HIGH="${INODE_HIGH:-82}"
80# Presión del recurso: max(% bytes usados, % inodos usados). Un vacío devuelve vacío, no 0 — un 0
81# inventado leería «holgado» justo cuando `df` no contesta.
82_presion() {
83  local pb pi
84  pb=$(df --output=pcent  "$1" 2>/dev/null | tr -dc '0-9')
85  pi=$(df --output=ipcent "$1" 2>/dev/null | tr -dc '0-9')
86  [ -z "$pb" ] && pb=0
87  [ -z "$pi" ] && pi=0
88  if [ "$pi" -gt "$pb" ]; then echo "$pi inodos"; else echo "$pb bytes"; fi
89}
90# Cuántos minutos se le respetan a un árbol EN FRÍO cuando el disco NO está apretado. Es la ventana
91# de post-mortem: por debajo de esto, un fallo se lleva su propia evidencia (ver protección 4).
92SUELO_FRIO_MIN="${SUELO_FRIO_MIN:-120}"
93(
94  while :; do
95    sleep 180
96    # Protección 1: árboles que un bwrap activo bind-monta (fase compile dentro del sandbox).
97    active=$(pgrep -af bwrap 2>/dev/null | grep -oE 'work/sources/[^ ]+' | sort -u)
98    # Protección 2: árboles cuya receta tiene un `takana build` EN VUELO. Durante el fetch/`go mod
99    # vendor`/resolve_phases (host-side, ANTES de que arranque el bwrap) el árbol no está bind-montado
100    # ⇒ la protección 1 no lo cubre. Una tanda Go con árbol de deps enorme (dnscontrol/lazysql) tarda
101    # minutos vendoreando; sin esto, el watchdog le borra el go.mod a mitad → "no build system".
102    # los DOS nombres del binario: el renombre hammer→takana dejó esta sonda ciega (ver
103    # estado-granja.sh). `hammer` sigue compilándose como alias, así que se aceptan ambos.
104    building=$(pgrep -af 'release/(takana|hammer)' 2>/dev/null | grep -oE '[^ ]+\.toml' | sed 's#.*/##;s#\.toml$##' | sort -u)
105    for d in work/sources/*/; do
106      [ -d "$d" ] || continue; dd=${d%/}
107      printf '%s\n' "$active" | grep -qxF "$dd" && continue
108      nm=$(basename "$dd"); nm=${nm%-*}   # work/sources/<name>-<sha40> → <name> (el sha no trae '-')
109      printf '%s\n' "$building" | grep -qxF "$nm" && continue
110      # Protección 3: árbol CALIENTE (modificado hace <2 min) ⇒ se está extrayendo AHORA. Una dep
111      # transitiva (p.ej. cairo bajo pango/gtk4) se extrae host-side ANTES del bwrap y con un nombre
112      # que NO coincide con la receta en vuelo ⇒ protecciones 1 y 2 no la cubren. Sin esto, el rm -rf
113      # compite con el tar y deja el árbol a medias ("Cannot mkdir" / luego "Directory not empty" para
114      # siempre). Un árbol en frío (extracción ya fallida/terminada) queda purgable ⇒ se re-extrae limpio.
115      # Protección 4: EL POST-MORTEM (2026-09-06). Con el suelo en 2 min, el árbol de una receta que
116      # ACABA DE FALLAR se barre antes de que nadie lo mire: `waterfox` murió a las 03:20:15 tras 36
117      # min y 23.980 pasos, enlazando `libxul.so`, y a las 03:22 `work/sources/` estaba VACÍO. El
118      # objdir con la evidencia se fue con él y reproducirlo cuesta otros 36 min.
119      #
120      # El repo ya tiene la doctrina escrita en otros dos sitios y este watchdog la contradecía:
121      # `campana-deuda.sh` dice «NO se poda tras un ✗: el árbol de una receta que falló es el
122      # post-mortem», y `poda-fuentes.sh` usa un suelo de 24 h justamente para «dejar el post-mortem
123      # del día».
124      #
125      # No se arregla subiendo el suelo a secas: este watchdog existe para que una tanda Go no llene
126      # el disco vendoreando, y ahí los minutos importan. Se arregla siendo agresivo SÓLO cuando el
127      # recurso escasea de verdad — con disco holgado, un árbol frío no le hace daño a nadie.
128      # Presión = max(bytes, inodos). Antes era sólo `--output=pcent` (bytes) y con los inodos a
129      # cero este suelo seguía respetando la ventana de post-mortem mientras nada podía escribir.
130      set -- $(_presion "$HAMMER_DIR"); usado="$1"; cual="$2"
131      suelo=$SUELO_FRIO_MIN
132      [ -n "$usado" ] && [ "$usado" -ge "$DISK_HIGH" ] && suelo=2   # recurso apretado ⇒ como antes
133      [ -n "$(find "$dd" -mmin -"$suelo" -print -quit 2>/dev/null)" ] && continue
134      rm -rf "$dd"
135    done
136    # ── ⚠ PURGAR CACHES PIDE PRESIÓN DE VERDAD, NO UN PORCENTAJE ──────────────────────────────
137    # `DISK_HIGH` es un PORCENTAJE, y en un disco de 196 G el 82 % deja **35 G libres**: de sobra
138    # para cualquier build. Con sólo el porcentaje, el disco pasa días en la banda 82-95 % y este
139    # bloque purga las caches cada 3 minutos — o sea que **ningún build vuelve a tener la caché de
140    # zig caliente**. Eso lo empeoré yo el 2026-10-10 al añadir zig a la purga: Go sólo afecta a las
141    # recetas Go; zig es el camino caliente del corpus ENTERO.
142    #
143    # Medido el 2026-10-13: 19 disparos en una hora. ⇒ la purga exige las DOS cosas, porcentaje alto
144    # **y** poco espacio ABSOLUTO. El suelo son 15 GiB, que es holgado para el árbol de fuentes más
145    # gordo medido (2,7 G del monorepo) y para los builds grandes, y deja la purga para cuando de
146    # verdad aprieta.
147    #
148    # ⚠ NO se toca el reaper de `work/sources` de arriba: podar un árbol frío es barato y hacerlo a
149    # menudo es correcto. Lo que no puede ser frecuente es tirar la caché del compilador. Mezclar
150    # las dos decisiones en un umbral es lo que hacía daño.
151    CACHE_SUELO_GIB="${CACHE_SUELO_GIB:-15}"
152    libres_gib=$(df --output=avail -BG "$HAMMER_DIR" 2>/dev/null | tr -dc '0-9')
153    set -- $(_presion "$HAMMER_DIR"); use="$1"; medidor="$2"
154    if [ -n "$use" ] && [ "$use" -ge "$DISK_HIGH" ] \
155       && { [ -z "$libres_gib" ] || [ "$libres_gib" -lt "$CACHE_SUELO_GIB" ]; }; then
156      # OJO: `go clean -modcache`/`-cache` borran ~/go/pkg/mod y ~/.cache/go-build ENTEROS. Ambos los
157      # usa `go mod vendor` (modcache p/ módulos, go-build p/ verificar el grafo). Si la purga corre
158      # mientras un vendor está activo —o en la VENTANA justo antes de que arranque (otra receta de la
159      # cola en serie)— le arranca el cache bajo los pies (`.partial`/`go-build/...: no such file`) y
160      # ESE vendor falla. La purga de work/sources de arriba ya liberó lo grueso (segura: saltea las
161      # protegidas). Por eso NO purgo las caches Go si hay CUALQUIER `takana build` en vuelo (no solo
162      # un `go mod vendor` visible en este instante): difiero al próximo tick / al gap idle entre
163      # ciclos. El sandbox `go install` usa GOCACHE=/src + -mod=vendor ⇒ no toca estas caches del host.
164      if pgrep -f 'go mod vendor' >/dev/null 2>&1 || pgrep -f 'release/(takana|hammer).* build ' >/dev/null 2>&1; then
165        echo "$(date -u +%FT%TZ) watchdog: ${medidor} al ${use}% y ${libres_gib:-?}G libres (< ${CACHE_SUELO_GIB}G) pero hay build en vuelo ⇒ difiero purga caches Go"
166      else
167        echo "$(date -u +%FT%TZ) watchdog: ${medidor} al ${use}% y ${libres_gib:-?}G libres (< ${CACHE_SUELO_GIB}G) ⇒ purgo caches Go y zig"
168        go clean -modcache 2>/dev/null; go clean -cache 2>/dev/null
169        # ── Y EL CACHE DE ZIG, que es el que de verdad tumbó esta caja (2026-10-07) ──────────────
170        # Este watchdog purgaba Go y de zig no sabía nada, y **zig es el compilador de TODO el
171        # corpus** (`compiler = "zig-cc"`). Medido: `.dev-fs/cache/zig` tenía ~11,4 M de inodos de
172        # 12,5 M usados en el fs — el 91 %. Ni `ls` terminaba ahí. Es cache puro (zig lo rehace; la
173        # primera tanda después va más lenta y ya), y NADIE lo podaba nunca: ni este watchdog, ni
174        # `poda-fuentes.sh`, ni la cosecha.
175        #
176        # ⚠ Por qué `mv` y DESPUÉS `rm -rf`, y no `rm -rf` directo: borrar millones de ficheros
177        # tarda minutos, y un build que arranque en el medio escribiría DENTRO del árbol que se está
178        # borrando. El rename es atómico: el build siguiente encuentra el sitio vacío y crea su
179        # cache nuevo mientras el viejo se va en paralelo. Mismo motivo por el que esto va dentro de
180        # la guarda de «ningún build en vuelo» que protege a las caches Go.
181        #
182        # La ruta sale igual que en `config.rs` (`cache_root = <proy>/.dev-fs/cache`), con el mismo
183        # override `CACHE` que acepta el build: si alguien lo mueve, el watchdog lo sigue.
184        zcache="${CACHE:-$HAMMER_DIR/.dev-fs/cache}/zig"
185        if [ -d "$zcache" ]; then
186          zbasura="$(dirname "$zcache")/.zig-borrando-$$"
187          if mv "$zcache" "$zbasura" 2>/dev/null; then
188            rm -rf "$zbasura" &
189            echo "$(date -u +%FT%TZ) watchdog: cache de zig apartado y borrándose en segundo plano"
190          fi
191        fi
192      fi
193    fi
194  done
195) &
196
197# Colas a construir (sólo SELLA, PROMOTE=0; el hub promueve+firma). `recipes/incoming` es la cola de
198# staging general; `recipes/incoming-go` es la AISLADA del frente Go (pre-sembrada para moler 24/7).
199# build-farm hace `rm -rf work/farm` al inicio ⇒ construirlas EN SERIE (una por vuelta) es seguro,
200# sin colisión. El glob es TOP-LEVEL (`$Q/*.toml`), así que `recipes/incoming/.deferred/` NO se
201# muele — es un subdir a propósito.
202#
203# ⚠ QUÉ HAY REALMENTE AHÍ (revisado el 2026-09-01, y no era lo que decía este comentario). Eran 24 y
204# quedan 19: se retiraron `dprint`, `git`, `gmp`, `mise` y `yj` porque **ya se habían resuelto en
205# otro fichero y sellan** (`recipes/git.toml`, `recipes/mise.toml`, `incoming-kde/gmp.toml`…). Su
206# copia aparcada era la versión ANTERIOR y seguía leyéndose como deuda abierta. El ejemplo que este
207# comentario daba —«git con muro en libgit.a»— era justamente uno de esos cinco.
208#
209# Y no son «aparcadas con diagnóstico»: las 19 que quedan son **volcado CRUDO del importador**, 15
210# de `import-nix` y 4 de `import-alpine`, todas con la misma cabecera «PUNTO DE PARTIDA, no final».
211# Nadie las intentó todavía; no hay ningún muro documentado que respetar. ⇒ Es una cola de trabajo
212# sin empezar, no una lista de imposibles, y conviene no leerla como lo segundo.
213# `incoming-clib` SE RETIRÓ el 2026-09-01: era la cola de staging de las tandas base-system/foot, y
214# sus recetas ya se habían PROMOVIDO a canónicas (`recipes/`) commit a commit —«promover 7 tier-2 a
215# canónicas», «promover cadena crypto», «promover tllist/utf8proc/scdoc/fcft/foot»—. Lo que quedaba
216# eran 0 recetas y 26 PARCHES sueltos. Y eran inalcanzables por construcción, no por descuido:
217# `fetch::apply_patches` resuelve `recipe.base_dir.join(nombre)` y `base_dir` es el directorio de la
218# receta, SIN fallback a `recipes/` ⇒ un parche en una cola sin recetas no lo puede pedir nadie.
219# ⚠ Corolario para cuando se promueva una cola: los `.patch` NO viajan con la receta. Promover deja
220#   la cola en un estado que parece vivo (tiene ficheros) y no muele nada.
221#
222# `incoming-go` NO existe como directorio y se queda en la lista A PROPÓSITO: es la cola aislada del
223# frente Go, y el guard `ls "$Q"/*.toml` salta las colas vacías. Quitarla sería el error que los dos
224# párrafos de abajo documentan, pero al revés: el día que ese frente la recree, nadie la molería.
225#
226# ⚠ EL ORDEN IMPORTA Y LAS COLAS GNOME VAN ANTES DE KDE (2026-08-06). Este default se escribió cuando
227# `incoming-gnome-onda1` era el frente GNOME entero, y quedó fósil: faltaban `incoming-gnome` (86
228# recetas, donde viven gtk4/mutter/gnome-shell) y `incoming-gnome-onda2` (la isla dinámica). O sea que
229# 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
230# aunque hubieran estado listadas, un ciclo con la cola KDE llena las dejaba sin turno.
231#
232# ⚠ EL MISMO FÓSIL, OTRA VEZ (2026-08-29). El párrafo de arriba se escribió por GNOME y la lección no
233# se aplicó a las colas que nacieron después: `incoming-cosmic` (47 recetas) e `incoming-wlr` (15)
234# NUNCA estuvieron acá. El worker decía moler la granja y no tocaba ni una de las 25 recetas en deuda
235# de COSMIC — no fallaban, es que nadie las intentaba. Es el mismo modo de fallo que "el corpus no
236# está en las QUEUES del worker": una cola ausente no da error, da silencio.
237# REGLA: crear una cola `recipes/incoming-*` y no añadirla acá es dejarla muerta. Si aparece una
238# nueva, va en esta línea EN EL MISMO COMMIT. Y la simétrica: si una cola se RETIRA, sale de acá en
239# el mismo commit, o el worker muele un glob vacío para siempre sin que nada lo diga.
240#
241# `incoming-gnome-onda1` SE RETIRÓ el 2026-09-01, y el motivo cierra el arco de los dos párrafos de
242# arriba: sus 6 recetas eran 4 duplicados byte a byte de `incoming-gnome` —mismo ArtifactHash, o sea
243# que sellaban en la MISMA dirección y entraban por cache-hit—, más `gnome-desktop` sin
244# `xkeyboard-config` en `[deps]`, más un `gsettings-desktop-schemas` con `introspection=false`, que
245# es la versión ANTERIOR al 2026-07-28 (mutter necesita el `GDesktopEnums-3.0.gir` que sale de ahí).
246# Al jubilar `gnome-desktop` las dos hojas de la cola se quedaron sin consumidor: molía para nadie.
247# ⚠ Lo caro no era el tiempo —todo salía por caché— sino que el `gsettings-desktop-schemas` viejo
248# competía por el NOMBRE con el bueno: dos artefactos homónimos con hash distinto en el store.
249#
250# `incoming-gnome-onda2` —«la isla dinámica» del párrafo de arriba— se retiró el mismo día, y es el
251# caso LIMPIO del mismo fenómeno: sus 22 recetas eran idénticas a las de `incoming-gnome` y las 22
252# con el MISMO ArtifactHash, o sea la misma dirección del store. Sin divergencias, sin colisión de
253# nombre y sin un solo artefacto que podar al retirarla. El andamio ya no sostenía nada: el trabajo
254# de la isla dinámica vive en `incoming-gnome`, que es la cola que el perfil usa (119/119).
255# ⇒ La comprobación que decide NO es `diff` de las recetas sino `takana hash`: la resolución de deps
256#   es hermano→padre, así que dos ficheros idénticos en colas distintas PUEDEN sellar distinto.
257# Orden: las colas con deuda van ANTES de las cerradas (kde 163/163, wlr 129/129 salen por cache-hit).
258QUEUES="${QUEUES:-recipes/incoming recipes/incoming-go recipes/incoming-gnome recipes/incoming-cosmic recipes/incoming-kde recipes/incoming-wlr}"
259
260# REBUILD DEL BINARIO AL ARRANCAR (2026-07-18): el takana horneado en la imagen golden ENVEJECE.
261# Caso real que costó una noche de KDE: la golden del 29-jun traía un takana sin el fix del overlay
262# lowerdir (commit e69eaaa, 12-jul) ⇒ toda receta de muchas deps (kio=52, kwin, plasma-*) desbordaba
263# el límite de 4KB del mount options y el sandbox NI ARRANCABA — 43 recetas atascadas jurando "en
264# cola". El source SÍ llega fresco (farm-sync rsync-ea el repo), pero nadie recompilaba. Reconstruir
265# aquí garantiza que el worker corre el CÓDIGO VIGENTE, no el fósil de la imagen. Cacheado: ~24s si
266# no cambió nada, minutos en frío. Si cargo falla, seguimos con el binario que haya (mejor que abortar).
267if command -v cargo >/dev/null 2>&1; then
268  echo "$(date -u +%FT%TZ) rebuild hammer desde el source rsync-eado (evita el fósil de la golden)…"
269  cargo build --release --bin takana --bin hammer -j"$JOBS" 2>&1 | tail -2 || echo "  ⚠ rebuild falló — uso el binario existente"
270fi
271
272# EL MISMO REBUILD, PERO POR CICLO Y NO SÓLO AL ARRANCAR (2026-09-06). El bloque de arriba corre
273# UNA vez, y este loop vive días: `uptime` de 8 días con el binario congelado en el arranque. El
274# source SÍ sigue llegando fresco cada 30 min (la siembra rsync-ea el repo), así que el worker
275# termina con FUENTE NUEVO y BINARIO VIEJO — que es peor que el fósil de la golden, porque nada lo
276# delata: el ArtifactHash NO se mueve por la versión de takana, así que el build se comporta
277# distinto y `build-state.json` sigue en verde.
278#
279# Caso real que lo motiva: el 2026-09-06 el worker corría un takana de las 04:54 y el arreglo que
280# materializa los submódulos git había entrado a las 15:11 (`114aeba`). `waterfox` moría en 4 s por
281# `waterfox/browser/locales/moz.build` inexistente y el diagnóstico apuntaba a la receta, a los
282# parches y al `--filter=blob:none` — a todo menos al binario. `strings` sobre los dos binarios lo
283# resolvió en un minuto: hub 2 cadenas de `.gitmodules`, worker 0.
284#
285# Se comprueba por MTIME y no se recompila a ciegas: `cargo` cacheado son ~24 s, pero correrlo cada
286# IDLE_SLEEP sin motivo es ruido en el log y CPU que le estamos quitando a un build. Si nada cambió,
287# esto cuesta un `find`.
288rebuild_si_hace_falta() {
289  command -v cargo >/dev/null 2>&1 || return 0
290  bin=target/release/takana
291  [ -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; }
292  # ¿Algún fuente más nuevo que el binario? (Cargo.lock incluido: un bump de dep también cuenta.)
293  if [ -n "$(find crates Cargo.toml Cargo.lock -newer "$bin" -type f -print -quit 2>/dev/null)" ]; then
294    echo "$(date -u +%FT%TZ) el source es más nuevo que el binario ⇒ recompilo hammer"
295    cargo build --release --bin takana --bin hammer -j"$JOBS" 2>&1 | tail -2 || echo "  ⚠ rebuild falló — sigo con el binario existente"
296  fi
297}
298
299echo "$(date -u +%FT%TZ) worker-loop arrancado: JOBS=$JOBS IDLE_SLEEP=$IDLE_SLEEP QUEUES='$QUEUES'"
300while :; do
301  rebuild_si_hace_falta
302  total=0
303  for Q in $QUEUES; do
304    n=$(ls "$Q"/*.toml 2>/dev/null | wc -l)
305    [ "$n" -gt 0 ] || continue
306    total=$((total + n))
307    echo "$(date -u +%FT%TZ) ciclo: $n recetas en $Q"
308    # LOCK COMPARTIDO CON campana-deuda.sh (2026-07-22): ambos construyen sobre el mismo work/, y
309    # `fetch` nombra el árbol de fuentes de forma determinista (`work/sources/<receta>-<sha16>`) ⇒
310    # dos procesos que construyan la misma DEP apuntan al mismo directorio y uno hace `remove_dir_all`
311    # mientras el otro corre `tar -x`, dejándolo a medias con "Directory not empty" (os error 39) para
312    # siempre. Se toma POR COLA y se suelta al terminarla, en vez de por bucle entero. Más fino no
313    # se puede sin romper el `xargs -P2` de build-farm.sh.
314    # OJO con la expectativa: `flock` NO es FIFO. Como el loop vuelve a pedirlo enseguida, suele
315    # ganarle la carrera a la campaña que espera; medido, la campaña entra al terminar TODAS las
316    # colas, no entre dos. La ventana real es el IDLE_SLEEP. Por eso la campaña espera con techo
317    # (LOCK_WAIT) y sale limpia si no entra, en vez de colgarse.
318    # NO cubre el `-P2` interno: dos recetas de la MISMA cola que compartan dep siguen pudiendo
319    # pisarse. Ese es el arreglo de fondo, un lock por árbol dentro de `fetch`.
320    # El `flock -n` primero es sólo para poder DECIR que estamos esperando: si el log dijera
321    # "ciclo: N recetas" y después nada, parecería que el loop trabaja cuando en realidad está
322    # bloqueado, y ese malentendido es exactamente el que cuesta horas de diagnóstico.
323    (
324      if ! flock -n 9; then
325        echo "$(date -u +%FT%TZ) esperando el lock de build (campana-deuda lo tiene)…"
326        flock 9
327        echo "$(date -u +%FT%TZ) lock tomado, sigo con $Q"
328      fi
329      # `9>&-` cierra el fd DEL LOCK en el hijo: si un build deja un proceso fugado, sin esto el
330      # nieto retiene el lock para siempre y la granja queda muda (medido 2026-09-08; ver la
331      # regla 1 de CLAUDE.md). Acá el lock vive en el subshell `( … ) 9>`, así que alcanza con
332      # cerrarlo en los hijos y no hace falta re-ejecutar nada.
333      JOBS="$JOBS" PROMOTE=0 QUEUE="$Q" scripts/build-farm.sh 2>&1 9>&- | tail -50 9>&-
334    ) 9>"$LOCK_FARM"
335  done
336  if [ "$total" -gt 0 ]; then
337    echo "$(date -u +%FT%TZ) ciclo terminado; store sellado disponible para el hub"
338  else
339    echo "$(date -u +%FT%TZ) colas vacías; durmiendo ${IDLE_SLEEP}s"
340  fi
341  sleep "$IDLE_SLEEP"
342done