granja: guardián de disco en campana-deuda — corta en vez de anotar ✗ falsos

La cascada KDE del 2026-09-03 llenó el disco en la receta 52 de 71 y siguió
moliendo: las 8 últimas murieron en 1-20 s con «No space left on device» y el
bucle las anotó ✗, el mismo símbolo que una receta que no compila. El grafo del
día siguiente decía «8 en deuda, clase c» y mandó a buscar un bug inexistente.

Mide el MÍNIMO de dos filesystems, no uno: en gioser store/ (/dev/sdb, 109 G) y
work/ (/dev/sdc, 53 G) son discos distintos y el que se llenó fue el de work/,
donde vive el árbol de fuentes. Mirando sólo el store el vigía habría leído
109 G y dejado moler igual.

Al cruzar el suelo corta con exit 3 y lista las recetas SIN INTENTAR, que no es
lo mismo que fallidas.
This commit is contained in:
Sergio
2026-09-04 13:09:17 +00:00
parent 6f145abe58
commit 3f26d37d17
+35
View File
@@ -72,12 +72,47 @@ echo "$(ts) campaña-deuda: lock de build tomado (el worker-loop espera su turno
total=$(echo "$DEUDA" | wc -w); i=0; ok=0; fail=0; falladas=""
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