tawasuyu

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 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 7c794568a963b18b68d747959e6bbd07cf2ebff7 scripts/build-farm.sh Shell · 244 siq'ikuna · 16.6 KB Ñawpa willay ch'uya

fix(granja): el diagnóstico mostraba el PRINCIPIO del log, no el fallo 2d935c7e3 · Sergio ·

1#!/bin/sh
2# build-farm.sh — Etapa G: GRANJA de build paralela sobre la COLA DE STAGING (recipes/incoming/).
3# El import escala (import-batch ~95% yield, cientos en minutos); el COMPILE es el cuello de botella
4# (1-7 min c/u) pero es embarrassingly-parallel + cacheable por contenido (el store dedup global). Esta
5# es la pieza que convierte "decenas por sesión serial" en "drenar la cola en paralelo": construye N a
6# la vez, y lo que CONSTRUYE+sella lo PROMUEVE incoming/->recipes/ y lo publica al repo firmado; lo que
7# falla queda staged con su motivo (MSRV, dep faltante, …) para revisión.
8#
9# Dos fases: (1) BUILD en paralelo (lo caro, worker pool de $JOBS) — escribe status por paquete; (2)
10# PROMOTE+PUBLISH en SERIE (el index.json del repo es mutable compartido ⇒ no se paraleliza) + firma 1 vez.
11# Idempotente: una receta ya sellada sale por cache-hit; re-correr sólo reintenta lo que falta.
12#
13# Uso:   JOBS=4 scripts/build-farm.sh                 # drena recipes/incoming/*.toml
14#        JOBS=6 QUEUE=recipes/incoming scripts/build-farm.sh
15# Env:   JOBS (def: nproc-2), QUEUE (def recipes/incoming), STORE (def ./store), REPO (def dist/repo),
16#        KEY (clave de firma; def dist/keys/release.ed25519), DISTRO (def dev), HAMMER (cmd del cli),
17#        PROMOTE (def 1; =0 sólo construye+mide, no toca recipes/ ni el repo).
18set -eu
19
20ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
21QUEUE="${QUEUE:-recipes/incoming}"
22STORE="${STORE:-./store}"
23REPO="${REPO:-$ROOT/dist/repo}"
24KEY="${KEY:-$ROOT/dist/keys/release.ed25519}"
25DISTRO="${DISTRO:-dev}"
26HAMMER="${TAKANA:-${HAMMER:-$ROOT/target/release/takana}}"
27PROMOTE="${PROMOTE:-1}"
28JOBS="${JOBS:-$(( $(nproc) - 2 ))}"; [ "$JOBS" -ge 1 ] || JOBS=1
29
30# Un subdir POR COLA: el worker-loop corre varias colas en serie por ciclo y con un dir compartido
31# la cola siguiente PISA los logs/status de la anterior (imposible diagnosticar fallos de la fase go
32# tras la fase clib). basename de $QUEUE ⇒ work/farm/incoming-go/{log,status}, etc.
33FARM="$ROOT/work/farm/$(basename "$QUEUE")"
34rm -rf "$FARM"; mkdir -p "$FARM/log" "$FARM/status"
35
36queue=$(ls "$QUEUE"/*.toml 2>/dev/null || true)
37[ -n "$queue" ] || { echo "cola vacía: $QUEUE/*.toml"; exit 0; }
38
39# ── NO REHACER LO QUE EL HUB YA SELLÓ ──────────────────────────────────────────────────────────
40# `farm-sync.sh` sincroniza el store en UN SOLO SENTIDO: sube el código EXCLUYENDO /store y baja el
41# store del worker. O sea que el worker nunca recibe los artefactos que se sellan en el hub — y por
42# tanto reintenta cada ciclo, y vuelve a fallar, trabajo que YA ESTÁ HECHO. Medido el 2026-08-07:
43# de 20 «fallos» de incoming-gnome, 17 correspondían a recetas con artefacto vigente en el hub.
44# No eran marcadores rancios (el status se borra en cada ciclo): era CPU quemada de verdad.
45#
46# Mandarle los artefactos costaría gigabytes por un enlace de 8 Mbps. Mandarle la LISTA cuesta unos
47# kilobytes: `work/farm-sellados.txt` son los nombres `<hash>-<paquete>` del store del hub. Si el
48# hash vigente de una receta está ahí, el hub ya la tiene y el worker se la salta.
49SELLADOS="${SELLADOS:-work/farm-sellados.txt}"
50if [ -s "$SELLADOS" ]; then
51  saltadas=0; cerradas=0; filtrada=""
52  for f in $queue; do
53    # ⚠ EL NOMBRE DEL ARTEFACTO ES EL CAMPO `name` DE LA RECETA, NO EL DEL FICHERO. Son distintos en
54    # 17 recetas del catálogo — el arquetipo es `recipes/incoming-kde/qtbase.toml`, cuyo `name` es
55    # `qt6-qtbase`, y el artefacto en el store se llama `<hash>-qt6-qtbase`. Usar el basename daba
56    # «no sellada» para las 17 y las habría mandado a reconstruir: exactamente el desperdicio que
57    # este filtro existe para evitar. (Lo tenía mal en la primera versión de este bloque.)
58    # ── RECETAS CERRADAS POR DECISIÓN: el marcador vive EN LA RECETA ─────────────────────────────
59    # Una receta puede estar sin sellar porque falta trabajo (deuda) o porque se decidió NO hacerla
60    # (alcance). La diferencia importa: la deuda invita a reintentar en cada ciclo; la decisión hay
61    # que respetarla, o la granja quema CPU para siempre en algo que nadie va a arreglar.
62    # El marcador va en la CABECERA DE LA RECETA, no en una lista aparte, justo para que no puedan
63    # desincronizarse: quien lee el porqué y quien decide saltarla miran el mismo texto.
64    if grep -q "CERRADA POR DECISIÓN" "$f" 2>/dev/null; then
65      cerradas=$((cerradas+1)); continue
66    fi
67    n=$(awk -F'"' '/^name[[:space:]]*=/{print $2; exit}' "$f")
68    [ -n "$n" ] || n=$(basename "$f" .toml)
69    H=$("$HAMMER" --store "$STORE" hash "$f" 2>/dev/null | tail -1 | sed 's/^b3://')
70    if [ -n "$H" ] && grep -qx "$H-$n" "$SELLADOS"; then saltadas=$((saltadas+1)); continue; fi
71    # ⚠ SALTO DE LÍNEA Y NO ESPACIO, y esto era un BUG que costó throughput en silencio
72    # (medido 2026-09-16): más abajo la cola se le pasa a `xargs -I{}`, que trata **una línea = un
73    # elemento**. Unidas por espacios, N recetas llegaban como UN SOLO argumento: la fase paralela
74    # fallaba siempre con «No such file» y la fase 1b —el reintento serial— salvaba EXACTAMENTE UNA,
75    # la última, porque `basename` de esa ristra da el último nombre. O sea que la granja molía
76    # **una receta por cola y por ciclo** en vez de la cola entera, y el log parecía normal: un ✗
77    # seguido de un ✓ (reintento serial). Se vio al encolar `lld21` junto a `rust` y descubrir que
78    # el ciclo decía «2 recetas» y construía una.
79    filtrada="$filtrada
80$f"
81  done
82  [ "$cerradas" -gt 0 ] && echo "  ($cerradas CERRADAS por decisión — ver la cabecera de cada receta)"
83  [ "$saltadas" -gt 0 ] && echo "  (saltadas $saltadas ya selladas en el hub — ver la nota del sync unidireccional)"
84  queue="$filtrada"
85  [ -n "$(echo $queue)" ] || { echo "nada que hacer en $QUEUE: todo sellado en el hub"; exit 0; }
86fi
87# `grep -v` porque el acumulador del filtro arranca con una línea vacía, y una línea vacía es un
88# elemento más para `xargs -I{}`: construiría la receta "" y ensuciaría el conteo.
89queue=$(printf '%s\n' "$queue" | grep -v '^[[:space:]]*$' || true)
90# ── ⚠ EL LÍMITE DE DESCRIPTORES, QUE SE LEE COMO RECETA ROTA (2026-10-11) ────────────────────
91# Medido en el worker: `obs-studio` y `spectacle` fallaban las dos con
92#
93#     error: failed to open object libavcodec/x86/rv34dsp_init.o: ProcessFdQuotaExceeded
94#     make: *** [ffbuild/library.mak:119: libavcodec/libavcodec.so.61] Error 1
95#
96# y no es un fallo de ffmpeg: **libavcodec tiene 1.093 ficheros `.o` y el límite BLANDO era 1024**.
97# El enlazador los abre a la vez y se pasa por 69. El límite DURO es 524288, así que subir el blando
98# no necesita privilegios — nadie lo había subido porque el default de 1024 alcanza para todo lo
99# demás del corpus.
100#
101# Es el tercer miembro de la misma familia, y conviene verlo junto: **bytes** (disco lleno ⇒ ✗ en
102# recetas sanas), **inodos** (`df -h` al 90 % y cero inodos ⇒ «0 fallan» mentiroso) y ahora **fds**.
103# Los tres son límites de la MÁQUINA que el informe presenta como defecto de la RECETA, y los tres
104# cuestan el mismo rato: alguien lee `obs-studio.toml` buscando un bug que no está ahí.
105#
106# Se sube acá y no en la unidad de systemd porque este guion VIAJA con el código: una unidad que
107# vive sólo en el worker se pierde el día que la caja se rehaga, y eso ya pasó con otras cosas.
108_subir_fds() {
109  local duro blando quiero=65536
110  duro=$(ulimit -Hn 2>/dev/null) || return 0
111  blando=$(ulimit -Sn 2>/dev/null) || return 0
112  [ "$duro" = "unlimited" ] && duro=$quiero
113  [ "$quiero" -gt "$duro" ] && quiero=$duro
114  if [ "$blando" -lt "$quiero" ] 2>/dev/null; then
115    # Se DICE lo que se hizo: un límite que cambia en silencio es un límite que nadie sabe que
116    # existe hasta que vuelve a estorbar.
117    ulimit -n "$quiero" 2>/dev/null && echo "==> fds: límite blando $blando → $quiero (duro: $duro)"
118  fi
119}
120_subir_fds
121
122n_total=$(printf '%s\n' "$queue" | grep -c . || true)
123echo "==> GRANJA: $n_total recetas en $QUEUE, $JOBS workers en paralelo"
124
125# ── Fase 1: BUILD en paralelo. Cada worker construye una receta y deja su status (.ok con hash | .fail
126# con motivo). El store sella atómico y cada RECETA usa sources/<name>-<sha> + out/<hash>-<name> propios.
127# CAVEAT: dos recetas DISTINTAS que comparten una dep transitiva NO sellada (p.ej. gtk4 bajo libadwaita
128# y gtksourceview) la construyen en el MISMO sources/<dep>-<sha>/output ⇒ chocan (meson/tar). Eso lo
129# concilia la Fase 1b (reintento serial). `xargs -P` es el pool.
130printf '%s\n' "$queue" | xargs -P"$JOBS" -I '{}' sh -c '
131  f="$1"; n=$(basename "$f" .toml); log="'"$FARM"'/log/$n.log"
132  H=$("'"$HAMMER"'" --store "'"$STORE"'" build "$f" 2>"$log" | tail -1 || true)
133  if [ "${H#b3:}" != "$H" ] && [ -n "$H" ]; then
134    echo "$H" > "'"$FARM"'/status/$n.ok"; printf "  ✓ %-18s %s\n" "$n" "$H"
135  else
136    # `ProcessFdQuota`/`Too many open files` en la lista porque el 2026-10-11 dos recetas sanas
137    # (`obs-studio`, `spectacle`) murieron por el límite de descriptores y la razón salió como
138    # «fallo (ver log)» — un fallo sin nombre se anota como deuda y se diagnostica leyendo la
139    # receta, que es donde no está. El límite ya se sube en este guion, así que esto es para el día
140    # que algo lo vuelva a tocar: que el informe lo DIGA en vez de callarlo.
141    r=$(grep -oiE "requires rustc [0-9.]+|cannot find -l[a-z0-9]+|could not find .[a-z0-9_-]+.|error\[E[0-9]+\]|ProcessFdQuotaExceeded|Too many open files|no space left on device|not found|unsupported|no such file" "$log" | head -1)
142    echo "${r:-fallo (ver log)}" > "'"$FARM"'/status/$n.fail"; printf "  ✗ %-18s %s\n" "$n" "${r:-fallo}"
143  fi
144' _ '{}'
145
146# ── Fase 1b: REINTENTO SERIAL de fallos por COLISIÓN de concurrencia. El pool paralelo puede hacer que
147# dos recetas construyan la MISMA dep transitiva compartida (gtk4/cairo/pango bajo varios consumidores)
148# a la vez en sources/<dep>-<sha>/output ⇒ meson/tar chocan ("Some other Meson process is already using
149# this build directory", "Directory not empty", "No such file"). Tras la Fase 1 la dep ya la selló el
150# ganador de la carrera ⇒ reconstruir el fallo EN SERIE ahora cache-hitea la dep y compila sin colisión.
151# Un solo pase basta: cada `takana build` arrastra sus deps, así que aunque el orden no sea topológico,
152# la dep se construye/sella dentro del propio reintento. Sólo se reintenta lo que tiene FIRMA de colisión
153# (no MSRV/dep-faltante reales, que re-fallarían y sólo gastarían tiempo).
154for s in "$FARM"/status/*.fail; do
155  [ -f "$s" ] || continue
156  n=$(basename "$s" .fail); log="$FARM/log/$n.log"; f="$QUEUE/$n.toml"
157  [ -f "$f" ] || continue
158  # ── `File name too long` TAMBIÉN es colisión (2026-08-09) ────────────────────────────────────
159  # `vulkan-loader` fallaba con «io: File name too long (os error 36)» en TODOS los ciclos de la
160  # granja y sella sin una queja ejecutado en serie — comprobado a mano en el worker. La firma no
161  # estaba en esta lista, así que nunca se reintentaba: fallaba, se contaba como deuda, y al ciclo
162  # siguiente volvía a fallar igual. Un fallo que se repite idéntico cada vez no es un fallo
163  # intermitente, es uno que nadie está reintentando.
164  if ! grep -qiE "Some other Meson process|already using this build directory|Directory not empty|No such file or directory|gdkconfig\.h|File name too long|os error 36" "$log" 2>/dev/null; then
165    # ── Y LO QUE NO SE REINTENTA, SE DICE ────────────────────────────────────────────────────────
166    # El defecto de fondo no era la firma que faltaba sino que faltarla fuese MUDO: la lista sólo
167    # puede crecer si alguien se entera de que se quedó corta. Sin esta línea, la única señal de que
168    # una receta es reintentable era que a alguien se le ocurriera correrla a mano.
169    # ⚠ LA COLA DEL LOG, NO LA CABEZA — y esto costó un paso real el 2026-10-11. Con `head` el
170    # mensaje salía así:
171    #   · obs-studio  no reintentado (firma no reconocida: … INFO takana_build: caché: artefacto ya…)
172    # o sea una línea de CACHE-HIT del arranque, mientras el fallo verdadero
173    # (`ProcessFdQuotaExceeded` al enlazar libavcodec) estaba al FINAL. Un diagnóstico que existe
174    # para nombrar la causa y muestra el principio del log no nombra nada: hay que ir a abrir el
175    # fichero igual, que es justo el paso que esta línea debía ahorrar.
176    printf "  · %-18s no reintentado (firma no reconocida: %s)\n" "$n" \
177      "$(tail -c 200 "$log" 2>/dev/null | tr '\n' ' ' | tr -s ' ')"
178    continue
179  fi
180  H=$("$HAMMER" --store "$STORE" build "$f" 2>"$log" | tail -1 || true)
181  if [ "${H#b3:}" != "$H" ] && [ -n "$H" ]; then
182    rm -f "$s"; echo "$H" > "$FARM/status/$n.ok"; printf "  ✓ (reintento serial) %-18s %s\n" "$n" "$H"
183  fi
184done
185
186# ── Fase 2: PROMOTE + PUBLISH en serie (índice del repo = recurso mutable compartido).
187ok=$(ls "$FARM"/status/*.ok 2>/dev/null | wc -l | tr -d ' ')
188fail=$(ls "$FARM"/status/*.fail 2>/dev/null | wc -l | tr -d ' ')
189# ── ⚠ LO QUE NO DEJÓ VEREDICTO NO ES UN ÉXITO NI UN FALLO: ES UN HUECO, Y HAY QUE DECIRLO ────
190# `ok` y `fail` se cuentan LISTANDO ficheros en `status/`, y los escribe el worker con
191# `echo … > status/$n.ok|.fail`. Si la caja no puede escribir —disco lleno, inodos agotados— **no
192# se escribe NINGUNO de los dos**, así que la receta no aparece en ninguna cuenta: desaparece.
193#
194# Y entonces la línea de abajo MIENTE de la peor forma posible, que es sonando bien. Medido en el
195# worker el 2026-10-07: **«BUILD-YIELD: 37/57 construyen (0 fallan)»** con 63.000 `No space left on
196# device` en el journal y la granja sin sellar nada desde nueve días antes. Las 20 que no
197# construyeron no se contaron como fallo **porque el mecanismo que registra el fallo lo rompía la
198# misma causa que el fallo**. Un ✓ falso es peor que un ✗ falso: el ✗ falso manda a leer una receta
199# sana, el ✓ falso dice que la granja va bien durante nueve días.
200#
201# Arreglarlo no es escribir el fichero de otra manera —si no hay disco, no hay disco— sino **contar
202# lo que falta**: `n_total` es la cola ya filtrada, así que `n_total - ok - fail` es exactamente lo
203# que se evaporó. Y se imprime la presión de los dos medidores al lado, porque la causa casi siempre
204# es ésa y buscarla a mano es el rato que se perdió la otra vez.
205mudas=$(( n_total - ok - fail ))
206echo "==> BUILD-YIELD: $ok/$n_total construyen ($fail fallan)"
207if [ "$mudas" -gt 0 ]; then
208  echo "==> ⚠ $mudas receta(s) SIN VEREDICTO: ni .ok ni .fail en $FARM/status/."
209  echo "    eso NO es «0 fallan» — es que no se pudo registrar el resultado. Presión del disco:"
210  echo "    bytes:  $(df --output=pcent "$FARM" 2>/dev/null | tr -dc '0-9')%   inodos: $(df --output=ipcent "$FARM" 2>/dev/null | tr -dc '0-9')%"
211fi
212
213if [ "$PROMOTE" = 1 ] && [ "$ok" -gt 0 ]; then
214  echo "==> promoviendo + publicando $ok al repo firmado…"
215  for s in "$FARM"/status/*.ok; do
216    n=$(basename "$s" .ok)
217    # GUARDA DE COLISIÓN: si ya existe una receta canónica en recipes/ y la cola trae
218    # OTRA variante con el mismo nombre, NO la promovemos (mv sobrescribiría lo canónico:
219    # p.ej. un `curl` mínimo-para-appstream pisando el curl 8.20 full de la que dependen tools).
220    # Queda staged en la cola para triage manual; tampoco re-publicamos sobre lo canónico.
221    if [ -f "$QUEUE/$n.toml" ] && [ -f "recipes/$n.toml" ]; then
222      printf "  = %s (ya existe recipes/%s.toml canónica — NO promovido, queda en %s)\n" "$n" "$n" "$QUEUE"
223      continue
224    fi
225    if [ -f "$QUEUE/$n.toml" ]; then
226      # mover la receta Y sus patches referenciados (viven junto al .toml en la cola);
227      # si no, `pack` no los encuentra en recipes/ y falla toda receta parcheada.
228      for p in $(grep -oE '"[^"]+\.patch"' "$QUEUE/$n.toml" | tr -d '"'); do
229        [ -f "$QUEUE/$p" ] && mv -f "$QUEUE/$p" recipes/
230      done
231      mv "$QUEUE/$n.toml" recipes/
232    fi
233    "$HAMMER" --store "$STORE" pack "recipes/$n.toml" --repo "$REPO" --distro-version "$DISTRO" --build >/dev/null 2>&1 \
234      && printf "  + %s\n" "$n" || printf "  ! %s (pack falló)\n" "$n"
235  done
236  "$HAMMER" repo sign --repo "$REPO" --key "$KEY" >/dev/null 2>&1 && echo "==> release re-firmado"
237fi
238
239# ── Reporte de fallos (motivo por paquete) para triage.
240if [ "$fail" -gt 0 ]; then
241  echo "==> FALLARON ($fail) — quedan staged en $QUEUE:"
242  for s in "$FARM"/status/*.fail; do printf "  ✗ %-18s %s\n" "$(basename "$s" .fail)" "$(cat "$s")"; done
243fi
244echo "==> granja: $ok promovidos, $fail staged. logs en $FARM/log/"