fix(granja): el límite de fds vivía en UN llamador, y la campaña manual no lo tenía
La cascada del PIC perdió `pata-centro` y `pata-llimphi` con
error: unable to search for static library '…/libmemchr-….rlib': ProcessFdQuotaExceeded
que es **el mismo límite de descriptores** que se arregló el 2026-10-11 cuando `obs-studio` y
`spectacle` morían con `failed to open object …rv34dsp_init.o: ProcessFdQuotaExceeded` (libavcodec
tiene 1.093 `.o` y el blando era 1024). Ese día se subió en `farm-worker-loop.sh` — **y sólo ahí**.
⇒ `campana-deuda.sh`, que es el guion OFICIAL para campañas manuales, nunca lo subió. Toda campaña
a mano estuvo expuesta ocho días, y se vio cuando una de 25 recetas pasó por ahí. **Un arreglo que
vive en un solo llamador es un arreglo que el siguiente llamador no tiene.**
La lógica se mueve a `scripts/lib/subir-fds.sh` y la usan los dos: el bucle del worker y la campaña.
No es una copia más — el loop pierde la suya, así que no pueden divergir. Probado ejercitando el
camino del tope: en esta jaula el límite duro es 4096 y el guion sube 1024 → 4096 en vez de fallar
pidiendo 65536.
⚠ Y la familia queda escrita en la librería, porque es lo que hace perder la tarde: **bytes** (disco
lleno ⇒ ✗ en recetas sanas), **inodos** (`df -h` al 90 % con cero inodos ⇒ «0 fallan» mentiroso) y
**fds**. Los tres son límites de la MÁQUINA que el informe presenta como defecto de la RECETA.
Y entra `scripts/farm/traer-artefactos.sh`, que es la operación que hice a mano tres veces hoy: la
cosecha ya NO baja el store (es lo que evita que el disco del hub se llene), así que lo que el worker
sella se queda allá — y el hub lo necesita para armar imágenes y medir la clausura. Lo que había era
todo o nada: `farm-sync.sh` baja el store ENTERO y `takana mirror pull` quiere una ruta local. El
guion trae por RECETA, con las cuatro precauciones que se olvidan y que olvidé una cada una antes de
escribirlo: `-H` (que `-a` no incluye, y sin él los hardlinks del CAS viajan como copias), excluir
`.dmerge` (caché pura), aterrizar por `.incoming-*` + `mv` (un artefacto a medias es un cache-hit
envenenado) y no pisar lo existente (`mv A B` con B presente lo ANIDA). Resuelve el nombre en el
corpus Y en las colas, porque pedir `recipes/<n>.toml` para algo de `incoming-kde` sale
`io: No such file or directory` — que es como perdí seis builds de esa misma tanda. Cuenta los
vacíos aparte de los ausentes y sale ≠0 si algo quedó sin traer. Probado trayendo los tres del PIC.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semantic delta
No definition changed: this change is text, documentation or data.
4 files · +155 −13
diff --git a/scripts/farm/campana-deuda.sh b/scripts/farm/campana-deuda.sh
index 2dfc8319..8d7450cc 100755
--- a/scripts/farm/campana-deuda.sh
+++ b/scripts/farm/campana-deuda.sh
@@ -45,6 +45,16 @@ DEUDA="${DEUDA:-$*}"
ts() { date -u +%FT%TZ; }
+# ── ⚠ EL LÍMITE DE DESCRIPTORES, QUE ESTE GUION NO SUBÍA (2026-10-19) ──────────────────────────
+# `farm-worker-loop.sh` lo sube desde el 2026-10-11 porque `obs-studio` y `spectacle` morían con
+# `ProcessFdQuotaExceeded` (libavcodec tiene 1.093 `.o` y el blando era 1024). Pero el arreglo vivió
+# sólo ahí, y ESTE guion —el oficial para campañas manuales— quedó expuesto: el 2026-10-19 una
+# campaña de 25 recetas perdió `pata-centro` y `pata-llimphi` con el mismo límite por el otro
+# camino (`unable to search for static library '…libmemchr….rlib': ProcessFdQuotaExceeded`).
+# Un arreglo que vive en un solo llamador es un arreglo que el siguiente llamador no tiene.
+. "$(cd "$(dirname "$0")/../lib" && pwd)/subir-fds.sh"
+subir_fds
+
# LOCK COMPARTIDO CON EL WORKER-LOOP (2026-07-22). Esta campaña y `farm-worker-loop.sh` 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
diff --git a/scripts/farm/farm-worker-loop.sh b/scripts/farm/farm-worker-loop.sh
index cdda9dbb..cbbb3008 100755
--- a/scripts/farm/farm-worker-loop.sh
+++ b/scripts/farm/farm-worker-loop.sh
@@ -40,19 +40,11 @@ cd "$HAMMER_DIR"
#
# Se sube acá y no en la unidad de systemd porque este guion VIAJA con el código: una unidad que
# vive sólo en el worker se pierde el día que la caja se rehaga, y eso ya pasó con otras cosas.
-_subir_fds() {
- local duro blando quiero=65536
- duro=$(ulimit -Hn 2>/dev/null) || return 0
- blando=$(ulimit -Sn 2>/dev/null) || return 0
- [ "$duro" = "unlimited" ] && duro=$quiero
- [ "$quiero" -gt "$duro" ] && quiero=$duro
- if [ "$blando" -lt "$quiero" ] 2>/dev/null; then
- # Se DICE lo que se hizo: un límite que cambia en silencio es un límite que nadie sabe que
- # existe hasta que vuelve a estorbar.
- ulimit -n "$quiero" 2>/dev/null && echo "==> fds: límite blando $blando → $quiero (duro: $duro)"
- fi
-}
-_subir_fds
+# ⇒ MOVIDO a `scripts/lib/subir-fds.sh` el 2026-10-19, porque la copia vivía sólo acá y
+# `campana-deuda.sh` —el camino de las campañas manuales— no la tenía: una tanda a mano perdió dos
+# recetas por el mismo límite. La lógica es idéntica; lo que cambia es que ahora hay UN sitio.
+. "$(cd "$(dirname "$0")/../lib" && pwd)/subir-fds.sh"
+subir_fds
JOBS="${JOBS:-1}"
IDLE_SLEEP="${IDLE_SLEEP:-300}"
diff --git a/scripts/farm/traer-artefactos.sh b/scripts/farm/traer-artefactos.sh
new file mode 100755
index 00000000..d189d1b2
--- /dev/null
+++ b/scripts/farm/traer-artefactos.sh
@@ -0,0 +1,98 @@
+#!/bin/sh
+# traer-artefactos.sh — trae del worker los artefactos VIGENTES que al hub le faltan, por receta.
+#
+# ══ POR QUÉ EXISTE ═════════════════════════════════════════════════════════════════════════════
+# La cosecha **ya no baja el store** (es lo que evita que el disco del hub se llene), así que lo que
+# el worker sella se queda allá hasta que alguien lo pida. Y el hub SÍ lo necesita: es donde se
+# arman las imágenes y donde `cierre-perfiles.py` mide la clausura.
+#
+# Lo que había era todo o nada: `farm-sync.sh` baja el store ENTERO y `takana mirror pull` quiere una
+# RUTA local (no sabe de ssh). Traer tres artefactos era copiar a mano un `rsync` con cuatro
+# precauciones que se olvidan — y las olvidé una vez cada una antes de escribir esto.
+#
+# ══ LAS CUATRO PRECAUCIONES, TODAS APRENDIDAS A GOLPES ═════════════════════════════════════════
+# 1. **`-H`**, que `-a` NO incluye. El store es content-addressed y `.dmerge` hardlinkea: sin
+# `-H` cada enlace viaja y se escribe como copia entera, y el tamaño del destino no se parece
+# al que informa `du` en el origen (medido: 25 G de origen llenaron 95 G de destino).
+# 2. **Excluir `.dmerge`**: es caché pura que takana reconstruye sola. Copiarla no aporta un
+# artefacto y es la parte más pesada del árbol.
+# 3. **Aterrizar por `.incoming-*` y después `mv`.** Un directorio de artefacto a medias es
+# EXACTAMENTE un cache-hit envenenado (regla 3 del CLAUDE.md): `build` mira presencia, así que
+# un árbol vacío o truncado se da por bueno para siempre. El `mv` dentro del mismo filesystem
+# es atómico.
+# 4. **No pisar lo que ya está.** `mv A B` con B existente lo ANIDA (B/A), y entonces el
+# artefacto queda con un nivel de más: presente, con contenido, y mintiendo.
+#
+# ══ QUÉ CUENTA COMO VIGENTE ════════════════════════════════════════════════════════════════════
+# El hash lo calcula `takana hash` sobre la receta DEL HUB, que es la fuente de verdad del
+# contrato. No se lee el nombre del directorio del worker ni su manifiesto: si el worker selló otra
+# cosa, lo que queremos saber es justamente que no coincide, no copiarla.
+#
+# Uso: scripts/farm/traer-artefactos.sh <receta|ruta.toml>...
+# scripts/farm/traer-artefactos.sh --lista fichero-con-una-receta-por-linea
+# Env: WORKER (def dev.gioser.net) · SSH_KEY (def ~/.ssh/id_ed25519) · STORE (def /store)
+# REMOTE_STORE (def /opt/takana/store) · TAKANA (def ./target/release/takana)
+set -u
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
+WORKER="${WORKER:-dev.gioser.net}"
+SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
+STORE="${STORE:-/store}"
+REMOTE_STORE="${REMOTE_STORE:-/opt/takana/store}"
+TAKANA="${TAKANA:-$ROOT/target/release/takana}"
+SSH="ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=20"
+
+[ -x "$TAKANA" ] || { echo "no encuentro el binario en $TAKANA" >&2; exit 1; }
+
+recetas=""
+if [ "${1:-}" = "--lista" ]; then
+ [ -r "${2:-}" ] || { echo "no puedo leer la lista ${2:-}" >&2; exit 2; }
+ recetas=$(grep -vE '^\s*(#|$)' "$2")
+else
+ [ $# -gt 0 ] || { sed -n '/^# Uso:/,/^# Env:/p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
+ recetas="$*"
+fi
+
+# Resolver nombre → ruta de receta. Una receta puede vivir en el corpus o en una cola, y acá eso
+# importa: pedir `recipes/<nombre>.toml` para algo que está en `incoming-kde` sale
+# `io: No such file or directory`, que no se lee como «está en otro sitio». Lo aprendí perdiendo
+# seis builds de una tanda de 22.
+ruta_de() {
+ case "$1" in *.toml) [ -f "$1" ] && echo "$1"; return ;; esac
+ [ -f "recipes/$1.toml" ] && { echo "recipes/$1.toml"; return; }
+ for d in recipes/incoming-*; do
+ [ -f "$d/$1.toml" ] && { echo "$d/$1.toml"; return; }
+ done
+ return 1
+}
+
+ya=0; traidos=0; faltan=0; vacios=0
+for r in $recetas; do
+ f=$(ruta_de "$r") || { printf '%-26s ⚠ no encuentro la receta (ni en el corpus ni en las colas)\n' "$r"; faltan=$((faltan+1)); continue; }
+ n=$(sed -n 's/^name[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$f" | head -1)
+ [ -n "$n" ] || n=$(basename "$f" .toml)
+ h=$("$TAKANA" --store "$STORE" hash "$f" 2>/dev/null | tail -1 | sed 's/^b3://')
+ case "$h" in ''|*[!0-9a-f]*) printf '%-26s ⚠ no pude calcular el hash\n' "$r"; faltan=$((faltan+1)); continue ;; esac
+ par="$h-$n"
+ if [ -d "$STORE/$par" ]; then printf '%-26s ya está en el hub\n' "$n"; ya=$((ya+1)); continue; fi
+
+ tmp="$STORE/.incoming-$par"
+ rm -rf "$tmp"
+ if ! rsync -azH --exclude '/.dmerge' -e "$SSH" "root@$WORKER:$REMOTE_STORE/$par/" "$tmp/" 2>/dev/null; then
+ printf '%-26s ⚠ no está en el worker (o el rsync falló): %s\n' "$n" "$h"
+ rm -rf "$tmp"; faltan=$((faltan+1)); continue
+ fi
+ c=$(find "$tmp" ! -type d 2>/dev/null | wc -l)
+ if [ "$c" -eq 0 ]; then
+ printf '%-26s ✗ llegó VACÍO — no lo sello (sería un cache-hit envenenado)\n' "$n"
+ rm -rf "$tmp"; vacios=$((vacios+1)); continue
+ fi
+ mv "$tmp" "$STORE/$par"
+ printf '%-26s ✓ %s entradas · %s\n' "$n" "$c" "$(du -sh "$STORE/$par" 2>/dev/null | cut -f1)"
+ traidos=$((traidos+1))
+done
+
+echo "== ya estaban $ya · traídos $traidos · sin traer $faltan · vacíos $vacios"
+# Un vacío NO es lo mismo que un ausente y por eso se cuentan aparte: el ausente falla ruidosamente
+# la próxima vez que alguien lo pida; el vacío, si se hubiera sellado, se daría por bueno para
+# siempre. Sale ≠0 si algo quedó sin traer, para que un llamador en un guion se entere.
+[ "$faltan" -eq 0 ] && [ "$vacios" -eq 0 ]
diff --git a/scripts/lib/subir-fds.sh b/scripts/lib/subir-fds.sh
new file mode 100644
index 00000000..57ca1686
--- /dev/null
+++ b/scripts/lib/subir-fds.sh
@@ -0,0 +1,42 @@
+# subir-fds.sh — sube el límite BLANDO de descriptores antes de construir. Se hace `source`.
+#
+# ══ POR QUÉ ESTO ES UNA LIBRERÍA Y NO UNA LÍNEA EN CADA GUION ══════════════════════════════════
+# El 2026-10-11 se midió en el worker que `obs-studio` y `spectacle` fallaban las dos con
+#
+# error: failed to open object libavcodec/x86/rv34dsp_init.o: ProcessFdQuotaExceeded
+#
+# y no era ffmpeg: **libavcodec tiene 1.093 ficheros `.o` y el límite blando era 1024**. El
+# enlazador los abre a la vez y se pasa por 69. El límite DURO es 524288, así que subirlo no
+# necesita privilegios — nadie lo había subido porque 1024 alcanza para todo lo demás del corpus.
+#
+# Se arregló **sólo en `farm-worker-loop.sh`**, y ahí quedó el hueco: el 2026-10-19 una campaña
+# manual de 25 recetas perdió `pata-centro` y `pata-llimphi` con
+#
+# error: unable to search for static library '…/libmemchr-….rlib': ProcessFdQuotaExceeded
+#
+# o sea el MISMO límite por el otro camino. El bucle del worker lo subía; la tanda lanzada a mano
+# no, porque `campana-deuda.sh` —el guion oficial de campañas— tampoco lo hacía. Un arreglo que
+# vive en un solo llamador es un arreglo que el siguiente llamador no tiene.
+#
+# ⚠ **Y SE LEE COMO RECETA ROTA.** Es el tercer miembro de la misma familia y conviene verlos
+# juntos: **bytes** (disco lleno ⇒ ✗ en recetas sanas), **inodos** (`df -h` al 90 % y cero inodos ⇒
+# «0 fallan» mentiroso) y **fds**. Los tres son límites de la MÁQUINA que el informe presenta como
+# defecto de la RECETA, y los tres cuestan el mismo rato: alguien abre el `.toml` a buscar un bug
+# que no está ahí.
+#
+# Uso: . "$(dirname "$0")/../lib/subir-fds.sh" # y después: subir_fds
+subir_fds() {
+ _sf_quiero=${1:-65536}
+ _sf_duro=$(ulimit -Hn 2>/dev/null) || return 0
+ _sf_blando=$(ulimit -Sn 2>/dev/null) || return 0
+ [ "$_sf_duro" = "unlimited" ] && _sf_duro=$_sf_quiero
+ # No pedir más que el duro: `ulimit -n` por encima del duro falla y dejaría el blando como
+ # estaba, en silencio.
+ [ "$_sf_quiero" -gt "$_sf_duro" ] 2>/dev/null && _sf_quiero=$_sf_duro
+ if [ "$_sf_blando" -lt "$_sf_quiero" ] 2>/dev/null; then
+ # Se DICE lo que se hizo: un límite que cambia en silencio es un límite que nadie sabe que
+ # existe hasta que vuelve a estorbar.
+ ulimit -n "$_sf_quiero" 2>/dev/null \
+ && echo "==> fds: límite blando $_sf_blando → $_sf_quiero (duro: $_sf_duro)"
+ fi
+}