Los llamadores EJECUTABLES: scripts/ (incluida toda la granja), los runbooks y CLAUDE.md. Seguro porque la 3a ya garantiza que el worker emite los dos binarios, y porque en farm-lab-sync.sh el cargo build remoto precede a la invocación remota en el mismo script. Verificado: sintaxis de los 49 (bash -n / py_compile — ojo que why-differs-barrido.sh es Python con extensión .sh) y `takana hash` devuelve hash real sobre el store. NO se toca en esta etapa, a propósito: - La variable de entorno HAMMER=. Es interfaz de los scripts entre sí y hay llamadores que la fijan; renombrarla va con la etapa 4. - docs/evidencia/ y los HANDOFF: son REGISTRO de lo que se corrió ese día. Reescribir un comando dentro de una evidencia la falsifica. - docs/state/: es generado, se regenera solo. - Los ADR y los docs de diseño: texto, y `hammer` sigue funcionando. Van con la etapa 5, que es la de churn de texto.
169 lines
11 KiB
Bash
Executable File
169 lines
11 KiB
Bash
Executable File
#!/bin/sh
|
||
# campana-deuda.sh — muele en el WORKER una lista EXPLÍCITA de recetas en deuda, medida en el HUB.
|
||
#
|
||
# POR QUÉ NO `saldar-deuda-static.sh` DIRECTO EN EL WORKER (2026-07-21): ese script calcula la deuda
|
||
# en vivo con `hammer hash --check` contra el store LOCAL. En el worker el store es PARCIAL (el del
|
||
# volumen: ~290 artefactos vs 720 del hub) ⇒ mide 716 recetas de deuda en vez de 37 y se pone a
|
||
# reconstruir media distro. Es la regla de [[frente-harkaq-jaula]] con otra cara: **el worker MIDE,
|
||
# el hub CLASIFICA**. Acá el hub decide la lista (`DRY=1 saldar-deuda-static.sh`) y el worker sólo
|
||
# ejecuta. La lista se pasa por argumentos o por DEUDA=, no se recalcula.
|
||
#
|
||
# ORDEN: las RAÍCES del stack GUI primero (glib unblocks=14, luego harfbuzz/cairo/pango → gtk4 →
|
||
# libadwaita). `hammer build` arrastra deps recursivamente, así que el orden no es obligatorio —
|
||
# pero empezar por la raíz hace que la cascada caiga cuanto antes y que un corte temprano deje el
|
||
# máximo destrabado. El stack GUI es lo que el laptop NO puede construir (zig-skew, ver
|
||
# [[etapa-g-gui-chain-boundary]]) ⇒ es exactamente lo que justifica la granja.
|
||
#
|
||
# PATH: `go mod vendor` y demás corren HOST-SIDE (fuera del sandbox) ⇒ el `go` del store tiene que
|
||
# estar en el PATH. Una sesión SSH no interactiva no carga /etc/profile ni cargo/env: sin esto toda
|
||
# receta Go muere con "spawn go mod vendor: No such file or directory".
|
||
#
|
||
# Uso (en el worker): scripts/farm/campana-deuda.sh <receta> [receta…]
|
||
# DEUDA="glib cairo" scripts/farm/campana-deuda.sh
|
||
# Lanzarlo desde el hub para que sobreviva al cierre del SSH:
|
||
# ssh root@<ip> 'systemd-run --unit=campana-deuda --working-directory=/opt/hammer \
|
||
# /opt/hammer/scripts/farm/campana-deuda.sh <recetas…>'
|
||
# ssh root@<ip> 'journalctl -u campana-deuda -f'
|
||
set -u
|
||
HAMMER_DIR="${HAMMER_DIR:-/opt/hammer}"
|
||
# Ruta absoluta a MÍ MISMO antes del `cd`: acá el `cd` va a HAMMER_DIR, que ni siquiera tiene por
|
||
# qué contener a este script, así que un `$0` relativo se pierde seguro.
|
||
YO="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
||
cd "$HAMMER_DIR"
|
||
HAMMER="${HAMMER:-$HAMMER_DIR/target/release/takana}"
|
||
STORE="${STORE:-$HAMMER_DIR/store}"
|
||
LOG="${LOG:-/var/log/campana-deuda.log}"
|
||
|
||
# go del store (host-side) + cargo, para que las recetas Go/Rust no mueran por PATH
|
||
for g in "$STORE"/*-go/usr/bin; do [ -d "$g" ] && PATH="$g:$PATH"; done
|
||
HOME="${HOME:-/root}"; export HOME # systemd-run no hereda HOME y `set -u` lo convierte en fatal
|
||
[ -r "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||
export PATH
|
||
|
||
DEUDA="${DEUDA:-$*}"
|
||
[ -n "$DEUDA" ] || { echo "uso: campana-deuda.sh <receta> [receta…] (o DEUDA=…)" >&2; exit 2; }
|
||
|
||
ts() { date -u +%FT%TZ; }
|
||
|
||
# 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
|
||
# directorio, y uno hace `remove_dir_all` mientras el otro corre `tar -x`. Eso deja el árbol a medias
|
||
# y devuelve "Directory not empty" (os error 39) — y el árbol roto se queda así hasta que alguien lo
|
||
# borra a mano. Pasó de verdad: la campaña y el loop pidieron `mesa` a la vez (el loop lo arrastraba
|
||
# desde la cola KDE) y se llevó puesta media cascada GUI.
|
||
#
|
||
# El lock es EXCLUSIVO y de grano grueso: la campaña lo toma para TODA su corrida y el loop lo toma
|
||
# por cada ciclo de cola. No se puede afinar más sin romper el `xargs -P2` del loop.
|
||
# `flock` NO es FIFO: como el loop vuelve a pedirlo enseguida, en la práctica la campaña entra en la
|
||
# ventana del IDLE_SLEEP, no entre dos colas (medido, no supuesto). De ahí el techo de espera.
|
||
#
|
||
# OJO, lo que este lock NO cubre: el propio `-P2` del loop puede correr dos recetas que compartan
|
||
# dep, y ésas se pisan igual. El arreglo de fondo es un lock POR ÁRBOL dentro de `fetch`.
|
||
#
|
||
# Espera bloqueante con techo: un ciclo de cola puede ser largo, pero colgarse para siempre sería
|
||
# peor que no correr. Al vencer, sale limpio y lo dice; el próximo lanzamiento reintenta.
|
||
LOCK_FARM="${LOCK_FARM:-$HAMMER_DIR/work/.farm-build.lock}"
|
||
LOCK_WAIT="${LOCK_WAIT:-7200}"
|
||
mkdir -p "$(dirname "$LOCK_FARM")"
|
||
# ⚠ EL LOCK LO SOSTIENE EL fd, Y LOS HIJOS LO HEREDAN (medido 2026-09-08). Con `exec 9>` + `flock 9`
|
||
# cualquier descendiente que se fugue retiene el lock DESPUÉS de que el script termine, y el próximo
|
||
# que lo pida espera para siempre sin que nada falle. Pasó: dos `firefox` colgados de una caza de
|
||
# bugs sobrevivieron al `kill` de su `bwrap` y dejaron a la granja hora y media sin poder compilar,
|
||
# en silencio. `fuser -v <lock>` es lo que lo delata.
|
||
# El arreglo es `flock -o`, que cierra el fd antes de ejecutar ⇒ el lock lo sostiene SÓLO el
|
||
# proceso `flock`, y ningún nieto puede prolongarlo. Como acá la sección crítica es el script
|
||
# ENTERO, se re-ejecuta bajo `flock -o` en vez de ir poniendo `9>&-` comando por comando, que es
|
||
# jugar a los topos y se olvida uno. `-E 77` distingue «el lock estaba ocupado» de «el trabajo
|
||
# falló», que con el `9>` de antes no se podían separar.
|
||
# Comprobado con control positivo y negativo: sin `-o` el nieto retiene; con `-o` no. Y que la
|
||
# exclusión mutua SIGUE funcionando, que es lo que un arreglo de locks puede romper sin avisar.
|
||
# ⚠ Ojo: `exec {L}>` (fd automático de bash) NO sirve — bash no lo marca close-on-exec. Medido.
|
||
if [ -z "${CAMPANA_DEUDA_CON_LOCK:-}" ]; then
|
||
export CAMPANA_DEUDA_CON_LOCK=1
|
||
rc=0; flock -w "$LOCK_WAIT" -E 77 -o "$LOCK_FARM" "$YO" "$@" || rc=$?
|
||
if [ "$rc" = 77 ]; then
|
||
echo "$(ts) campaña-deuda: el worker-loop no soltó el lock en ${LOCK_WAIT}s ⇒ salgo sin construir" | tee -a "$LOG"
|
||
exit 0
|
||
fi
|
||
exit "$rc"
|
||
fi
|
||
echo "$(ts) campaña-deuda: lock de build tomado (el worker-loop espera su turno)" | tee -a "$LOG"
|
||
total=$(echo "$DEUDA" | wc -w); i=0; ok=0; fail=0; falladas=""; ultimo_rc=0
|
||
echo "$(ts) campaña-deuda arranca: $total recetas | go=$(command -v go || echo AUSENTE)" | tee -a "$LOG"
|
||
|
||
# ── GUARDIÁN DE DISCO (2026-09-04) ──────────────────────────────────────────────────────────────
|
||
# POR QUÉ. La cascada KDE del 2026-09-03 llenó el disco en la receta 52 de 71 y siguió moliendo: las
|
||
# 8 restantes murieron cada una en 1-20 s con `error in backend: IO failure on output stream: No
|
||
# space left on device`, y el bucle las anotó `✗` — el MISMO símbolo que una receta que de verdad no
|
||
# compila. Al día siguiente el grafo decía «8 en deuda, clase c» y eso mandó a leer recetas durante
|
||
# un rato buscando un bug que no existía. Un disco lleno no es un fallo de receta: es un fallo de la
|
||
# MÁQUINA, y confundirlos cuesta el triaje entero.
|
||
#
|
||
# Además el trabajo posterior al corte es puro desperdicio: 4 minutos quemados produciendo ✗.
|
||
#
|
||
# DÓNDE SE MIDE: en DOS filesystems, no en uno. En gioser `store/` y `work/` son discos DISTINTOS
|
||
# (`/dev/sdb` el store, `/dev/sdc` el resto del repo) y el que se llenó esa noche fue el de `work/`:
|
||
# ahí viven `work/sources/<receta>-<sha>` y el árbol de build, que es lo que crece ~300 MB por
|
||
# receta. Un guardián que mirara sólo el store habría leído 109 G libres y dejado moler igual —
|
||
# el fallo clásico de calibrar el vigía en una escala que el dato nunca alcanza. Se toma el MÍNIMO
|
||
# de los dos: cualquiera de los dos lleno mata el build igual.
|
||
#
|
||
# Suelo en GiB. 8 G es ~1,5 veces el árbol+build de la receta más gorda que vimos (kwin, qt6); por
|
||
# debajo de eso el siguiente build es una apuesta, no una tarea.
|
||
SUELO_GIB="${SUELO_GIB:-8}"
|
||
avail_gib() { df -B1G --output=avail "$1" 2>/dev/null | tail -1 | tr -d ' '; }
|
||
libres_gib() {
|
||
a="$(avail_gib "$STORE")"; b="$(avail_gib "$HAMMER_DIR/work")"
|
||
[ -n "$a" ] || { echo "$b"; return; }
|
||
[ -n "$b" ] || { echo "$a"; return; }
|
||
[ "$a" -le "$b" ] && echo "$a" || echo "$b"
|
||
}
|
||
|
||
for r in $DEUDA; do
|
||
i=$((i + 1))
|
||
f="recipes/$r.toml"
|
||
[ -r "$f" ] || f="$(ls recipes/incoming*/"$r".toml 2>/dev/null | head -1)"
|
||
[ -n "$f" ] && [ -r "$f" ] || { echo "$(ts) [$i/$total] $r ⚠ sin receta" | tee -a "$LOG"; continue; }
|
||
echo "$(ts) [$i/$total] → $r" | tee -a "$LOG"
|
||
libres="$(libres_gib)"
|
||
if [ -n "$libres" ] && [ "$libres" -lt "$SUELO_GIB" ]; then
|
||
echo "$(ts) [$i/$total] ⛔ DISCO: ${libres} G libres (min de store=$(avail_gib "$STORE") y work=$(avail_gib "$HAMMER_DIR/work")) < suelo ${SUELO_GIB} G ⇒ corto la campaña" | tee -a "$LOG"
|
||
echo "$(ts) campaña-deuda ABORTADA por disco: $ok selladas, $fail fallidas, $((total - i + 1)) SIN INTENTAR" | tee -a "$LOG"
|
||
echo "$(ts) sin intentar: $(echo "$DEUDA" | tr ' ' '\n' | tail -n +"$i" | tr '\n' ' ')" | tee -a "$LOG"
|
||
exit 3
|
||
fi
|
||
# heartbeat: el dead-man ya cuenta `hammer build` como trabajo, pero entre receta y receta hay
|
||
# huecos (fetch/vendor) donde no hay proceso con ese nombre. Tocarlo evita una muerte espuria.
|
||
touch /run/hammer-heartbeat 2>/dev/null || true
|
||
# build-timed.sh mide la pared y registra la duración en $STORE/.times (sólo builds reales), para
|
||
# el camino crítico pesado de yupana. Transparente: sale con el mismo código que `hammer build`.
|
||
if HAMMER="$HAMMER" STORE="$STORE" "$HAMMER_DIR/scripts/farm/build-timed.sh" "$f" >>"$LOG" 2>&1; then
|
||
ultimo_rc=0; ok=$((ok + 1)); echo "$(ts) [$i/$total] ✓ $r" | tee -a "$LOG"
|
||
else
|
||
ultimo_rc=1; fail=$((fail + 1)); falladas="$falladas $r"; echo "$(ts) [$i/$total] ✗ $r" | tee -a "$LOG"
|
||
fi
|
||
# PODA_FUENTES=1 — borra el árbol de fuentes DENTRO del bucle, que es el único sitio donde es
|
||
# seguro: acá tenemos el lock Y acabamos de terminar un build, así que no hay ningún `bwrap`
|
||
# usando un árbol. Es lo contrario de correr `poda-fuentes.sh` en paralelo, que sería el ADR 0012
|
||
# en su forma más directa (su propio encabezado avisa que el mtime NO distingue «viejo» de «lento»).
|
||
# No cuesta nada: `fetch.rs` borra y re-extrae el árbol en CADA build, no lo reutiliza jamás.
|
||
# Nace en la cascada de kcoreaddons (2026-09-03), 71 recetas KDE en una máquina con 16 G libres:
|
||
# a ~300 MB de árbol por receta, sin esto la campaña se come el disco antes de la onda 4.
|
||
#
|
||
# POR DEFECTO 1 (2026-09-04). Nació apagado y la cascada KDE del 2026-09-03 corrió sin ponerlo:
|
||
# 71 recetas × ~300 MB de árbol llenaron /dev/sdc en la 52 y se llevaron puestas las 8 últimas.
|
||
# Un default que hay que acordarse de encender no es una defensa. Apagar con PODA_FUENTES=0.
|
||
#
|
||
# NO se poda tras un ✗: el árbol de una receta que falló es el post-mortem — sin él no se puede
|
||
# leer qué generó cmake ni con qué flags murió, y es justo el caso en que alguien va a mirar.
|
||
# Es el mismo criterio del suelo de 24 h de poda-fuentes.sh, aplicado por resultado y no por edad.
|
||
if [ "${PODA_FUENTES:-1}" = 1 ] && [ "$ultimo_rc" = 0 ]; then
|
||
rm -rf "$HAMMER_DIR"/work/sources/* 2>/dev/null || true
|
||
fi
|
||
done
|
||
|
||
echo "$(ts) campaña-deuda fin: $ok selladas, $fail fallidas de $total" | tee -a "$LOG"
|
||
[ -n "$falladas" ] && echo "$(ts) fallidas:$falladas" | tee -a "$LOG"
|
||
exit 0
|