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

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>

24fa7ec83c0045616ecea5af6696ac6a6a0554e8 · ruwaq Sergio · (2 p'unchaw ñawpaq) · tayta f6d792ac6 · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

4 qillqakuna · +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
+}