250 líneas de comentario en 151 scripts. Control verificado: el diff no toca NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa. El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces: las tres eran el MOTD que el script escribe DENTRO de la imagen construida — texto del producto, no comentario del script. Se cambiaron aparte y a propósito, que es rebranding, no limpieza. Y el hallazgo caro: casaba contra , que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate. La etapa 4 lo movió a y el script quedó casando NADA. No fallaba: imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja. Además 14 rutas de módulo en docs, que el barrido anterior no tocó porque no es frontera de palabra.
399 lines
29 KiB
Bash
Executable File
399 lines
29 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# cosecha-cron.sh — el LATIDO de la granja: recoger + sembrar, en bucle, sin agente ni tokens.
|
||
#
|
||
# QUÉ HACE cada ciclo, por cada worker vivo en scripts/farm/.fleet:
|
||
# 1. SIEMBRA (sube): rsync de código + recetas laptop→worker (el laptop es canónico; si autoré
|
||
# recetas nuevas —p.ej. openrc— el worker las empieza a moler sin que yo intervenga).
|
||
# 2. RECOGE (baja): rsync del store sellado worker→laptop (CAS ⇒ merge seguro, sin conflicto).
|
||
# 3. Regenera el GRAFO DE ESTADO (build-state{,-kde,-gnome,-cosmic}.json) desde el store ya
|
||
# cosechado, pasa los VIGÍAS (fuentes, contrato del kernel, y el audit de enlace estático con
|
||
# puerta diaria) y commitea SÓLO esos ficheros firmes. El `git log` de docs/state/ ES el avance
|
||
# que el humano ve y sigue (huecos, fallos, qué se selló entre dos ciclos).
|
||
#
|
||
# LO QUE **NO** HACE (a propósito): NO promueve incoming-kde→recipes/ canónico (esa es decisión de
|
||
# clasificación humana — "el hub clasifica", ver granja-promote-colisiones) ni firma el índice. Sólo
|
||
# captura al store del laptop y deja el veredicto para la mañana. Idempotente y re-corrible.
|
||
#
|
||
# INFINITO: vive en el crontab hasta que lo saques (`crontab -e`, borrar la línea cosecha-cron).
|
||
# Robusto a worker-ausente: si el dead-man ya mató al worker (cola seca ⇒ €0), el paso 1/2 falla
|
||
# suave, se loguea y igual regenera+commitea el estado local. NO relanza workers (eso gasta €;
|
||
# es decisión explícita con farm-up).
|
||
#
|
||
# Uso: scripts/farm/cosecha-cron.sh # un ciclo (lo que dispara el cron)
|
||
# */30 * * * * cd /home/sergio/hammer && scripts/farm/cosecha-cron.sh >>work/cosecha-cron.log 2>&1
|
||
# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/hammer), NO_COMMIT=1 (regenera pero no commitea)
|
||
set -uo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||
# Ruta absoluta a MÍ MISMO, capturada ANTES del `cd`: se usa para re-ejecutarse bajo el lock, y
|
||
# un `$0` relativo dejaría de resolver en cuanto cambiamos de directorio.
|
||
YO="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
|
||
cd "$ROOT"
|
||
|
||
# LOCK (2026-07-22): el latido ya no viene sólo del crontab — `latido.sh` lo lanza desde la sesión
|
||
# porque el laptop arranca a veces con arje-zero y a veces con OpenRC, y cronie sólo existe en uno
|
||
# de los dos. Con dos disparadores posibles, dos ciclos pueden solaparse: ambos hacen rsync sobre el
|
||
# mismo store y, peor, `git commit`+`push` a la vez (index.lock, o un push que pisa al otro). El lock
|
||
# va ACÁ y no en el llamador para que valga venga de donde venga: cron, latido o a mano.
|
||
# -n = no esperar: si ya hay un ciclo corriendo, este sobra (el próximo tick recoge lo mismo).
|
||
LOCKFILE="${LOCKFILE:-$ROOT/work/.cosecha-cron.lock}"
|
||
mkdir -p "$(dirname "$LOCKFILE")"
|
||
# ⚠ 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 "${COSECHA_CRON_CON_LOCK:-}" ]; then
|
||
export COSECHA_CRON_CON_LOCK=1
|
||
rc=0; flock -n -E 77 -o "$LOCKFILE" "$YO" "$@" || rc=$?
|
||
if [ "$rc" = 77 ]; then
|
||
echo "── $(date -u +%FT%TZ) cosecha-cron: ya hay un ciclo en curso ⇒ salgo (no me solapo)"
|
||
exit 0
|
||
fi
|
||
exit "$rc"
|
||
fi
|
||
|
||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
|
||
REMOTE="${REMOTE:-/opt/hammer}"
|
||
FLEET="$ROOT/scripts/farm/.fleet"
|
||
# ── SIEMBRA DE LA FLOTA FIJA ────────────────────────────────────────────────────────────────────
|
||
# `.fleet` es estado de EJECUCIÓN (gitignored, lo reescribe el reaper cada ciclo); `flota-permanente`
|
||
# es la parte versionada. Sin esto, un hub recién clonado nace con la flota vacía y la granja queda
|
||
# desconectada EN SILENCIO: «flota vacía» cada 30 min, los artefactos del worker no vuelven, y nada
|
||
# falla. Se unen por NOMBRE (el fijo gana) para que lo efímero de hcloud conviva sin duplicarse.
|
||
FLEET_FIJA="$ROOT/scripts/farm/flota-permanente"
|
||
if [ -s "$FLEET_FIJA" ]; then
|
||
# Los `|| true` NO son adorno: este script corre con `set -o pipefail`, y un `.fleet` ausente
|
||
# —que es EXACTAMENTE el caso que esta siembra existe para arreglar— hace fallar al `cat`, la
|
||
# tubería hereda ese estado y el `&& mv` no dispara. Resultado medido: el fichero se generaba
|
||
# bien y se quedaba sin mover, o sea que la siembra fallaba justo en el único caso que importa.
|
||
{ grep -vE '^[[:space:]]*(#|$)' "$FLEET_FIJA" || true; cat "$FLEET" 2>/dev/null || true; } \
|
||
| awk 'NF && !visto[$1]++' > "$FLEET.seed" && mv "$FLEET.seed" "$FLEET"
|
||
fi
|
||
HAMMER="${TAKANA:-${HAMMER:-$ROOT/target/release/takana}}"
|
||
# Workers efímeros reciclan IPs ⇒ no fijar known_hosts (hub-and-spoke, worker sin secretos).
|
||
SSH="ssh -i $SSH_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=15"
|
||
|
||
ts() { date -u +%FT%TZ; }
|
||
echo "── $(ts) cosecha-cron arranca"
|
||
|
||
if [ ! -s "$FLEET" ]; then
|
||
echo " flota vacía (scripts/farm/.fleet) — nada que cosechar este ciclo"
|
||
else
|
||
while read -r name ip; do
|
||
[ -n "${name:-}" ] || continue
|
||
echo "==> $name ($ip)"
|
||
# 1. SIEMBRA: código + recetas laptop→worker (--delete: el laptop manda; nunca toca work/store/target).
|
||
# ⚠ LA EXCLUSIÓN DE PNG VA ANCLADA, Y NO ES ESTILO (2026-09-06). Antes decía `--exclude '*.png'`,
|
||
# sin anclar, o sea PNG en CUALQUIER sitio — incluido `recipes/`. Dos consecuencias, las dos malas:
|
||
#
|
||
# a) rsync PROTEGE DE BORRADO lo que excluye. Un directorio cuyo único resto es un `.png` no se
|
||
# puede vaciar y por lo tanto no se puede borrar NUNCA, aunque el hub lo haya eliminado.
|
||
# Medido: `recipes/atuq/extension/atuq128.png` sobrevivió a un rename hecho en el hub y el
|
||
# log de la siembra repetía `cannot delete non-empty directory: recipes/atuq/extension`.
|
||
# b) Un PNG NUEVO de una receta tampoco viaja. Y hay recetas cuyo `[source] dir` tiene PNG
|
||
# dentro: los 9 iconos de `recipes/atuq/branding/icons/`.
|
||
#
|
||
# Como takana SÍ hashea el árbol de un `[source] dir` (`recipe.rs`, `dir:<hash>`), el efecto no es
|
||
# silencioso —bendito sea—: el worker calcula OTRO ArtifactHash. Medido el mismo día,
|
||
# `atuq` daba `b3:f2960991` en el hub y `b3:b33e81a5` en el worker por ESE único fichero de más.
|
||
# O sea que el worker no podía coincidir con el hub ni construyendo bien.
|
||
#
|
||
# Lo que se quería ahorrar eran las capturas: `docs/evidencia/` (4,4 M) y el PNG suelto de la raíz
|
||
# (1 M). Eso se excluye por RUTA. Los PNG de `recipes/` viajan, que es lo correcto: son fuente.
|
||
if rsync -az --delete -e "$SSH" \
|
||
--exclude /work --exclude /store --exclude '/store-*' --exclude /target \
|
||
--exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
|
||
--exclude '/docs/evidencia/' --exclude '/*.png' --exclude '/content*' \
|
||
./ "root@$ip:$REMOTE/" 2>&1 | tail -2; then
|
||
echo " siembra ✓"
|
||
else
|
||
echo " ⚠ siembra falló (worker ausente/idle-muerto?) — sigo con el siguiente"
|
||
continue
|
||
fi
|
||
# 2. RECOGE: store sellado worker→laptop (CAS, merge seguro).
|
||
# ── ⚠ EXCLUIR LO YA PODADO, O LA POda SE DESHACE SOLA ─────────────────────────────────────
|
||
# El worker NO se poda nunca, así que conserva los artefactos superados y este rsync los
|
||
# devolvía enteros. Con el latido cada 30 min, una poda de 24 G quedaba deshecha en menos de
|
||
# una hora: medido el 2026-08-07, tres podas seguidas borraron LOS MISMOS 362 artefactos.
|
||
#
|
||
# El mismo arreglo está en `farm-sync.sh` — y ahí estuvo el error de método: lo puse allí, di el
|
||
# bucle por cerrado, y la churn siguió porque **hay DOS rutas que bajan el store** y el latido
|
||
# usa ésta. Arreglar una de dos copias y declarar victoria es peor que no arreglar: el síntoma
|
||
# se atenúa lo justo para dejar de mirar. Si aparece una tercera copia, va con el mismo exclude.
|
||
#
|
||
# ── 2026-08-09: EL STORE YA NO BAJA. BAJA EL MANIFIESTO ───────────────────────────────────
|
||
# El store se mudó al VOLUMEN de la granja y el laptop se quedó con lo que la granja no puede
|
||
# rehacer. Seguir bajando el store acá desharía esa mudanza cada 30 minutos — la misma forma
|
||
# exacta del bucle de churn que este bloque documenta arriba, y la razón por la que el exclude
|
||
# de abajo dejó de alcanzar: ya no se trata de filtrar QUÉ baja, sino de que no baja.
|
||
#
|
||
# Lo que el hub sí necesita es SABER qué hay sellado, para que el grafo de estado no reporte
|
||
# `never` sobre artefactos que existen. Eso son kilobytes: la lista de nombres. Se acumula en
|
||
# el manifiesto (nunca se pisa) porque el volumen es una sola foto y el hub recuerda todas.
|
||
if $SSH "root@$ip" "ls $REMOTE/store 2>/dev/null | grep -E '^[0-9a-f]{64}-'" \
|
||
> "$ROOT/work/.sellados-worker" 2>/dev/null && [ -s "$ROOT/work/.sellados-worker" ]; then
|
||
N_W=$(wc -l < "$ROOT/work/.sellados-worker")
|
||
cat "$ROOT/work/farm-sellados.txt" "$ROOT/work/.sellados-worker" 2>/dev/null \
|
||
| grep -E '^[0-9a-f]{64}-' | sort -u > "$ROOT/work/farm-sellados.txt.new"
|
||
mv "$ROOT/work/farm-sellados.txt.new" "$ROOT/work/farm-sellados.txt"
|
||
rm -f "$ROOT/work/.sellados-worker"
|
||
echo " manifiesto ✓ (worker: $N_W · total: $(wc -l < "$ROOT/work/farm-sellados.txt"))"
|
||
else
|
||
echo " ⚠ no pude leer el manifiesto del worker — sigo"
|
||
fi
|
||
# ⚠ Lo que NO cubre esto: los artefactos nuevos quedan SÓLO en el volumen hasta que alguien
|
||
# haga una pasada de respaldo. El volumen tiene borrado protegido, pero es UNA copia.
|
||
done < "$FLEET"
|
||
fi
|
||
|
||
# 2.5 REAPER HUB-SIDE (2026-07-23). El dead-man DEL WORKER probó FRÁGIL: un worker quedó 3.5h idle
|
||
# quemando € porque su `/etc/hammer-deadman.env` no tenía token válido ⇒ el dead-man llegaba a
|
||
# matarse pero salía 1 "sin HCLOUD_TOKEN" cada tick. La garantía "un vps idle es inadmisible" NO
|
||
# puede depender de que cada worker se auto-provisione bien un token (falla en silencio). El HUB sí
|
||
# tiene un token que funciona, y este latido corre cada 30 min aunque no haya sesión (setsid) ⇒ el
|
||
# hub es la AUTORIDAD de vida del worker. Es defensa en profundidad: el dead-man del worker cubre
|
||
# "hub caído"; esto cubre "dead-man del worker roto". Un worker sin trabajo activo REAPER_MAX ciclos
|
||
# seguidos se BORRA desde acá, con el MISMO blindaje que el dead-man (label role=takana-worker;
|
||
# gioser no tiene label ⇒ jamás pasa, ni por accidente).
|
||
REAPER_MAX="${REAPER_MAX:-2}" # 2 ciclos × 30 min ≈ 1 h idle, igual que el dead-man del worker
|
||
if [ -s "$FLEET" ] && command -v hcloud >/dev/null 2>&1; then
|
||
mkdir -p "$ROOT/work/.reaper"; sobreviven=""
|
||
while read -r name ip; do
|
||
[ -n "${name:-}" ] || continue
|
||
# LISTA NEGRA (capa 1 de 2, igual que el dead-man): gioser JAMÁS se toca, pase lo que pase.
|
||
# Segunda capa = el chequeo de label abajo. gioser no tiene label Y no matchea worker de la
|
||
# granja ⇒ imposible que muera por acá. "Ahí está nuestra vida" (2026-07-23).
|
||
case "$name" in *gioser*) echo "==> reaper: $name en LISTA NEGRA ⇒ intocable"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue;; esac
|
||
strike_f="$ROOT/work/.reaper/$name"
|
||
# ¿el server siquiera existe? Si ya no está en hcloud (borrado a mano o por un ciclo previo),
|
||
# NO cuesta € y no debe quedar en .fleet para siempre. Se dropea (no entra a `sobreviven`).
|
||
if ! hcloud server describe "$name" -o format='{{.Name}}' >/dev/null 2>&1; then
|
||
# ⚠ «No está en hcloud» son DOS casos muy distintos, y antes se trataban igual (2026-09-08):
|
||
# · un worker PERMANENTE que no es de Hetzner — hoy el LXC prestado `dev.gioser.net`, que
|
||
# no cuesta € ⇒ NO HAY NADA QUE SEGAR, y sacarlo de .fleet desconecta la granja EN
|
||
# SILENCIO: la cosecha pasa a decir «flota vacía» y los artefactos del worker dejan de
|
||
# volver, sin que nada falle. Es el mismo fallo mudo que este fichero ya tuvo.
|
||
# · un server efímero ya borrado a mano ⇒ ése sí sobra en .fleet para siempre.
|
||
# Se distinguen preguntándole a la MÁQUINA, no al nombre. Antes el LXC sólo se salvaba porque
|
||
# su nombre contiene la subcadena «gioser» y caía en la lista negra de arriba — una lista que
|
||
# existe para proteger al HUB de Hetzner y no tiene nada que ver con él. Medido: el mismo host
|
||
# llamado `pruebasia-lxc` era expulsado de .fleet en el primer ciclo.
|
||
if $SSH root@"$ip" true 2>/dev/null; then
|
||
echo "==> reaper: $name no es de hcloud pero RESPONDE ⇒ worker permanente, se queda (€0)"
|
||
sobreviven="${sobreviven}${name} ${ip}
|
||
"
|
||
else
|
||
echo "==> reaper: $name no está en hcloud y no responde ⇒ lo saco de .fleet"; rm -f "$strike_f"
|
||
fi
|
||
continue
|
||
fi
|
||
if $SSH root@"$ip" 'pgrep -f "hammer build|campana-deuda|farm-worker-loop" >/dev/null 2>&1'; then
|
||
echo 0 > "$strike_f"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue
|
||
fi
|
||
strikes=$(( $(cat "$strike_f" 2>/dev/null || echo 0) + 1 )); echo "$strikes" > "$strike_f"
|
||
echo "==> reaper: $name SIN trabajo activo — strike $strikes/$REAPER_MAX"
|
||
if [ "$strikes" -lt "$REAPER_MAX" ]; then sobreviven="${sobreviven}${name} ${ip}
|
||
"; continue; fi
|
||
# BLINDAJE (igual que el dead-man): sólo se borra un server con label role=takana-worker.
|
||
if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
|
||
echo " ☠ IDLE $strikes ciclos ⇒ el HUB borra $name (label verificado; gioser protegido)"
|
||
if hcloud server delete "$name" >/dev/null 2>&1; then rm -f "$strike_f"
|
||
else echo " ⚠ delete falló — sobrevive, reintenta próximo ciclo"; sobreviven="${sobreviven}${name} ${ip}
|
||
"; fi
|
||
else
|
||
echo " ⛔ $name SIN label hammer-worker ⇒ NO se borra (protegido)"; sobreviven="${sobreviven}${name} ${ip}
|
||
"
|
||
fi
|
||
done < "$FLEET"
|
||
printf '%s' "$sobreviven" | grep -v '^[[:space:]]*$' > "$FLEET.tmp" 2>/dev/null; mv "$FLEET.tmp" "$FLEET" 2>/dev/null || true
|
||
fi
|
||
|
||
# 2.7 PODA DE `work/sources` (2026-08-31). Cuarto vigía, y el único que no emite veredicto: su
|
||
# salida es que el disco se quede plano.
|
||
#
|
||
# POR QUÉ EXISTE: ese día el volumen del store (`/dev/sdb`, 246 G) estaba al **97%, 8,8 G libres**,
|
||
# y el gordo NO era el store — eran **79 G de `work/sources`**, 106 árboles con el `target/` de
|
||
# cargo y los `.o` dentro (el árbol de fuentes es también el directorio de compilación). Nada en la
|
||
# granja podaba eso: `store-gc.sh` mira el store, la poda de caché de CI mira la caché de CI, y este
|
||
# tercer montón crecía sin dueño desde que el store se mudó al volumen. Se recuperaron 70 G.
|
||
#
|
||
# POR QUÉ ES SEGURO: no es una caché. Los dos caminos de `takana-build/src/fetch.rs` borran el árbol
|
||
# y lo re-extraen en CADA build, sin una sola rama que reutilice ⇒ un árbol viejo no acelera nada y
|
||
# borrarlo no cuesta ni la re-extracción. Lo único que se pierde es el post-mortem del último build
|
||
# de esa receta, y por eso el suelo son 24 h y no cero.
|
||
#
|
||
# CADA CICLO, no con puerta diaria como el audit: el `find` sobre ~100 directorios es instantáneo y
|
||
# el `rm` cuesta en proporción a la basura que haya. Con puerta diaria la basura de un día entero de
|
||
# granja llegaría junta, que es justo lo que se quiere evitar.
|
||
#
|
||
# El script toma `work/.farm-build.lock` por dentro y con espera acotada: borrar el árbol que un
|
||
# `bwrap` está compilando es el ADR 0012 en su forma más directa, y el mtime no distingue "viejo" de
|
||
# "build lento". Si hay algo en vuelo, salta el ciclo y sale 0.
|
||
if [ -x "$ROOT/scripts/poda-fuentes.sh" ]; then
|
||
"$ROOT/scripts/poda-fuentes.sh" --aplicar || echo " ⚠ poda de fuentes falló (rc=$?) — sigo"
|
||
fi
|
||
|
||
# 3. Regenerar el grafo de estado desde el store ya cosechado (usa el takana del laptop, que tiene
|
||
# el subcomando `hash`). Barato (~14s cada uno). El corpus canónico y el frente KDE, por separado.
|
||
echo "==> regenerando grafo de estado"
|
||
if [ -x "$HAMMER" ]; then
|
||
scripts/build-state.py >/dev/null 2>&1 && echo " build-state.json ✓" || echo " ⚠ build-state.py falló"
|
||
scripts/build-state.py --kde >/dev/null 2>&1 && echo " build-state-kde.json ✓" || echo " ⚠ build-state.py --kde falló"
|
||
# GNOME faltaba acá (2026-07-27): el latido regeneraba base+KDE y NUNCA el grafo GNOME, así que
|
||
# build-state-gnome.json envejecía en silencio y reportaba `never` sobre recetas selladas hacía
|
||
# días (gobject-introspection, toda la onda 2). Un grafo viejo miente con la misma cara que uno
|
||
# fresco — por eso lo regenera el latido y no la mano.
|
||
scripts/build-state.py --gnome >/dev/null 2>&1 && echo " build-state-gnome.json ✓" || echo " ⚠ build-state.py --gnome falló"
|
||
# COSMIC (2026-08-03), por la MISMA razón por la que se agregó GNOME arriba: un frente que no
|
||
# regenera el latido envejece en silencio, y un grafo viejo miente con la misma cara que uno fresco.
|
||
scripts/build-state.py --cosmic >/dev/null 2>&1 && echo " build-state-cosmic.json ✓" || echo " ⚠ build-state.py --cosmic falló"
|
||
# wlr/sway (2026-08-26), TERCERA vez que cae el mismo cable. El flag `--wlr` se escribió el día que
|
||
# se abrió el frente, y su propio comentario en build-state.py dice «sin esto el frente es INVISIBLE
|
||
# para el khipu» — pero la línea nunca se agregó ACÁ. Resultado medido: build-state-wlr.json se
|
||
# quedó congelado el 2026-08-09 diciendo `escritorio-sway 121/121`, y 17 días después la realidad
|
||
# era 108/122: freetype, make, python3 y libpng-pic se re-hashearon en el medio y arrastraron a 14
|
||
# dependientes a deuda. Nadie lo vio porque el número que todo el mundo mira estaba perfecto.
|
||
# Un grafo viejo miente con la misma cara que uno fresco — y el que nadie regenera miente MÁS,
|
||
# porque envejece en la dirección optimista: sólo puede sobreestimar lo que hay sellado.
|
||
scripts/build-state.py --wlr >/dev/null 2>&1 && echo " build-state-wlr.json ✓" || echo " ⚠ build-state.py --wlr falló"
|
||
# keystones y duplicados (2026-09-08). CUARTA vez que cae el mismo cable: los comentarios de
|
||
# arriba lo cuentan ya para GNOME (2026-07-27), COSMIC (2026-08-03) y wlr/sway (2026-08-26), y
|
||
# estos dos se habían quedado congelados el 2026-09-05 — TRES DÍAS. Con un agravante que los
|
||
# otros no tenían: `keystones` es el fichero que responde «¿qué construyo después?», así que
|
||
# rancio no se limita a envejecer, DESINFORMA. Medido: decía «1 nudo en deuda, camino crítico
|
||
# corpus/firefox → corpus/atuq» con los dos sellados hacía horas. Un grafo viejo miente con la
|
||
# misma cara que uno fresco, y éste además manda a trabajar en lo que ya está hecho.
|
||
scripts/yupana.py keystones >/dev/null 2>&1 && echo " keystones.json ✓" || echo " ⚠ yupana keystones falló"
|
||
scripts/yupana.py duplicados >/dev/null 2>&1 && echo " duplicados.json ✓" || echo " ⚠ yupana duplicados falló"
|
||
# Cola DERIVADA del grafo (P4): el orden de masticado por imagen, en ondas topológicas. No lanza
|
||
# ningún build — sólo deja escrito qué conviene moler primero, para que la cola deje de ser una
|
||
# lista escrita a mano. Barato (lee JSON, no hashea).
|
||
# NO se traga la salida entera: drenar.py sale 1 cuando alguna imagen quedó SIN MEDIR (una cola
|
||
# nueva sin su línea en GRAFOS), y con `>/dev/null 2>&1` eso llegaba al log como un "falló" mudo
|
||
# que no dice cuál. Se filtran las líneas ⚠, que son las únicas accionables.
|
||
if drenaje_out="$(scripts/drenar.py --todos 2>&1)"; then
|
||
echo " drenaje.json ✓"
|
||
else
|
||
echo " ⚠ drenar.py falló"
|
||
echo "$drenaje_out" | grep '⚠' | sed 's/^/ /'
|
||
fi
|
||
# VIGÍA DE FUENTES (ADR 0013 §3). Desde que el mirror propio se consulta ANTES que upstream,
|
||
# `takana build` deja de avisar cuando una URL de terceros muere: sirve los bytes del mirror y
|
||
# sigue. Eso es lo correcto para construir y sería CIEGO sin este contrapeso — las URLs se irían
|
||
# muriendo una a una y el corpus resultaría irreconstruible el día que falte el mirror, con todo
|
||
# en verde hasta ese momento. Igual que el grafo de wlr, que pasó 17 días anunciando un 121/121
|
||
# que ya era falso: lo que nadie refresca envejece hacia el optimismo.
|
||
# No descarga: pide cabeceras. Primera corrida (2026-08-26): 8 muertas de 1167, y TRES de ellas
|
||
# —busybox, freetype, freetype-shared— selladas y en uso.
|
||
scripts/fuentes/fuentes-vigia.sh >/dev/null 2>&1 && echo " fuentes-vigia.json ✓" || echo " ⚠ fuentes-vigia falló"
|
||
# CONTRATO DE CAPACIDADES DEL KERNEL (SDD 25 §4.ter). Mismo cable que el vigía de fuentes y por la
|
||
# misma razón: el gate existía y NADIE lo corría. Un kernel reconstruido sin `MEMCG` volvería a
|
||
# pasar inadvertido, y ese fallo **no se ve del lado del desarrollo** —el kernel de esta máquina sí
|
||
# lo trae—, sólo comprobando el artefacto. Acá el veredicto queda en docs/state/ y el `git log` lo
|
||
# muestra solo, como el resto del khipu.
|
||
# Se escribe por temporal y sólo se mueve si tiene contenido: `contract` sale != 0 cuando el
|
||
# contrato NO se cumple —y ese rojo es justo lo que hay que guardar—, pero un fichero VACÍO se
|
||
# leería como «nada que objetar», que es el fallo de CLAUDE.md §3.
|
||
"$HAMMER" --store ./store kernel contract --sealed > work/.kernel-contract.tmp 2>&1
|
||
if [ -s work/.kernel-contract.tmp ]; then
|
||
mv work/.kernel-contract.tmp docs/state/kernel-contract.txt
|
||
echo " kernel-contract.txt ✓ $(grep -m1 '^resumen' docs/state/kernel-contract.txt || echo '(sin resumen)')"
|
||
else
|
||
rm -f work/.kernel-contract.tmp
|
||
echo " ⚠ kernel contract no produjo salida — NO piso el veredicto anterior"
|
||
fi
|
||
# AUDIT DE ENLACE ESTÁTICO (`link = "static"`), CON PUERTA DIARIA. Tercer cable de la misma
|
||
# familia que el vigía de fuentes y el contrato del kernel, y por la misma razón exacta: el gate
|
||
# existía desde julio, nadie lo re-corría, y el 2026-08-31 el "MIENTEN: 0" con el que se había
|
||
# cerrado el frente resultó ser 4 — dwarves, htop, wlr-randr y gtk4, todas selladas DESPUÉS del
|
||
# cierre. Un frente que se cierra en cero y no se vuelve a medir no se queda en cero.
|
||
# Lo que costaba ese silencio, medido ese mismo día: `bash` —el shell de los perfiles base, cli y
|
||
# dos escritorios— no ARRANCABA fuera del sandbox, y las cuatro apps GTK4 del corpus
|
||
# (hammer-edit incluida) segfalteaban en `gtk_init`. Todo sellado y en verde.
|
||
#
|
||
# PUERTA DIARIA, no cada ciclo: el barrido recorre los ~750 sellados leyendo cada ejecutable con
|
||
# `file`+`readelf` y tarda ~8 min de CPU en 4 vCPU que además comparte con el worker. Cada 30 min
|
||
# sería más de un cuarto de core para siempre vigilando algo que sólo cambia cuando se re-sella
|
||
# una receta. Con la puerta, es 8 min al día.
|
||
#
|
||
# El sello es un LIMITADOR DE RITMO, no una marca de éxito ⇒ se toca en cuanto el barrido termina,
|
||
# salga como salga. Si sólo se tocara al acertar, un fallo que tarde los 8 min se repetiría cada
|
||
# 30 y el cable pasaría de vigilancia a sangría. El veredicto anterior está protegido aparte, por
|
||
# el mismo temporal-no-vacío del contrato del kernel.
|
||
# Para forzarlo a mano: `rm work/.static-audit.sello` y esperar al próximo ciclo.
|
||
#
|
||
# NO toma `work/.farm-build.lock` a propósito: sólo LEE el store, y retener el lock 8 min pararía
|
||
# la granja entera. `nice -n 19` porque la prioridad la tiene construir, no auditar.
|
||
AUDIT_SELLO="work/.static-audit.sello"
|
||
if [ ! -f "$AUDIT_SELLO" ] || [ -n "$(find "$AUDIT_SELLO" -mtime +0 2>/dev/null)" ]; then
|
||
# ── VIGÍA DE SONAMES (2026-09-06) ────────────────────────────────────────────────────────────
|
||
# `vigia-sonames.py` existía desde antes y contesta la pregunta que el grafo NO contesta: no
|
||
# «¿está sellado?» sino «¿arranca?». Encuentra los `NEEDED` que ningún artefacto del cierre
|
||
# publica y que el rootfs hidratado resolvía contra el sysroot del LAB.
|
||
#
|
||
# NADIE LO CORRÍA. Por eso `libstdc++.so.6` —que rompía el navegador en los CUATRO perfiles—
|
||
# estuvo en su salida sin que nadie lo leyera, y se redescubrió el 2026-09-06 arrancando `atuq`
|
||
# a mano. Un vigía que hay que acordarse de invocar no se distingue de no tenerlo: es el mismo
|
||
# argumento que `scripts/test-atuq-politica.py` hace sobre los guardianes.
|
||
# Deja fichero de estado para que la salida quede en el repo y se vea si empeora entre cosechas.
|
||
python3 scripts/vigia-sonames.py > docs/state/sonames.txt 2>&1 \
|
||
&& echo " sonames.txt ✓ $(grep -c 'FALTA' docs/state/sonames.txt) hueco(s) sin proveedor" \
|
||
|| echo " ⚠ vigia-sonames.py falló"
|
||
|
||
echo " static-audit: puerta diaria abierta, barriendo el store (~8 min, nice 19)…"
|
||
nice -n 19 scripts/static-audit.sh > work/.static-audit.salida 2>&1
|
||
audit_rc=$?
|
||
touch "$AUDIT_SELLO"
|
||
if [ -s work/.static-audit.salida ]; then
|
||
# La primera línea lleva la FECHA DE MEDICIÓN, y no es decoración. Con el frente en verde el
|
||
# texto del audit es constante ("MIENTEN: 0") ⇒ `git diff --cached --quiet` no vería cambio,
|
||
# no se commitearía nada, y dentro de tres meses este fichero sería indistinguible de uno
|
||
# rancio de hoy. Con la fecha, cada barrido deja huella en el `git log`: el khipu muestra que
|
||
# el vigía sigue vivo, no sólo que el último veredicto fue bueno. Es la misma trampa que el
|
||
# build-state-wlr congelado 17 días — un fichero que nadie refresca envejece hacia el
|
||
# optimismo, y acá la prueba de frescura es lo único que distingue "verde" de "sin mirar".
|
||
{ echo "audit de enlace estático · medido $(date -u +%FT%TZ) · rc=$audit_rc"
|
||
cat work/.static-audit.salida
|
||
} > work/.static-audit.tmp
|
||
mv work/.static-audit.tmp docs/state/static-audit.txt
|
||
echo " static-audit.txt ✓ (rc=$audit_rc) $(grep -m1 '^══' docs/state/static-audit.txt || echo '(sin resumen)')"
|
||
else
|
||
echo " ⚠ static-audit no produjo salida (rc=$audit_rc) — NO piso el veredicto anterior"
|
||
fi
|
||
rm -f work/.static-audit.salida work/.static-audit.tmp
|
||
else
|
||
echo " static-audit: sello de hoy, no toca (puerta diaria)"
|
||
fi
|
||
else
|
||
echo " ⚠ sin binario hammer en $HAMMER — no regenero estado"
|
||
fi
|
||
|
||
# 4. Commitear SÓLO el estado firme (el avance que el humano sigue). Recetas nuevas que YO autoré se
|
||
# commitean aparte, a mano, para no meter autoría a medias en un commit de cron.
|
||
if [ "${NO_COMMIT:-}" != "1" ]; then
|
||
git add docs/state/sonames.txt docs/state/build-state.json docs/state/build-state-kde.json docs/state/build-state-gnome.json docs/state/build-state-cosmic.json docs/state/build-state-wlr.json docs/state/drenaje.json docs/state/fuentes-vigia.json docs/state/kernel-contract.txt docs/state/static-audit.txt docs/state/keystones.json docs/state/duplicados.json 2>/dev/null || true
|
||
if ! git diff --cached --quiet 2>/dev/null; then
|
||
# ⚠ ACOTADO POR PATHSPEC (`-- <rutas>`), no sólo por el `git add` de arriba: el índice es
|
||
# estado COMPARTIDO y `git commit` a secas commitea el índice ENTERO, así que lo que otro
|
||
# agente dejara en `git add` viajaría dentro del commit del cron. Es la regla 2 de CLAUDE.md,
|
||
# y acá pesa más que en ningún lado: esto corre desatendido cada 30 min mientras se trabaja.
|
||
git commit -q -m "estado: cosecha granja $(ts) — avance del árbol KDE" -- docs/state/sonames.txt docs/state/build-state.json docs/state/build-state-kde.json docs/state/build-state-gnome.json docs/state/build-state-cosmic.json docs/state/build-state-wlr.json docs/state/drenaje.json docs/state/fuentes-vigia.json docs/state/kernel-contract.txt docs/state/static-audit.txt docs/state/keystones.json docs/state/duplicados.json 2>/dev/null \
|
||
&& { git push -q origin main 2>/dev/null && echo "==> estado commiteado+pusheado" \
|
||
|| echo "==> estado commiteado (push falló, reintenta próximo ciclo)"; }
|
||
else
|
||
echo "==> sin cambios de estado este ciclo"
|
||
fi
|
||
fi
|
||
echo "── $(ts) cosecha-cron fin"
|