Si el push del estado se rechaza porque otro agente empujó primero, el ciclo siguiente se rechaza IGUAL —nadie rebasa nunca— y el commit del cron se queda local para siempre. Se encontró en la caja: rama divergente con un `estado: cosecha granja` atascado y la cosecha diciendo «reintenta» cada media hora sin que nada cambiase. El mensaje tranquilizaba y no era cierto. Ahora, cuando lo rechazan, llama a `git-sincronizar.sh`, que es la regla 2 ter automatizada: rebasa, COMPRUEBA por parche que el commit sobrevivió, lo recupera del reflog si no, y empuja. Y si tampoco puede, lo dice y deja el comando para verlo, en vez de prometer un reintento que no existe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
471 lines
34 KiB
Bash
Executable File
471 lines
34 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/takana && scripts/farm/cosecha-cron.sh >>work/cosecha-cron.log 2>&1
|
||
# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/takana), 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/takana}"
|
||
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/takana-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.
|
||
# ── LOS DOS VIGÍAS NUEVOS (2026-09-12) ───────────────────────────────────────────────────────
|
||
# Van FUERA de la puerta diaria porque son baratos —medidos: 4 s y 22 s— y dentro del `if` del
|
||
# sello sólo correrían una vez al día sin motivo.
|
||
#
|
||
# El argumento para cablearlos es el mismo que dejó escrito `vigia-sonames` tres líneas más
|
||
# abajo, y que costó `libstdc++.so.6` roto en los cuatro perfiles: **un vigía que hay que
|
||
# acordarse de invocar no se distingue de no tenerlo**. Los dos nacieron hoy encontrando cosas
|
||
# reales —46 binarios inertes por falta de driver; 4 comandos de bzip2 apuntando a `/out`— y las
|
||
# dos las encontré a mano. Eso no se repite solo.
|
||
#
|
||
# Dejan fichero en `docs/state/` con la FECHA DE MEDICIÓN por delante, por la misma razón que el
|
||
# static-audit: con el frente en verde el texto es constante, `git diff --cached --quiet` no vería
|
||
# cambio, y dentro de tres meses el fichero sería indistinguible de uno rancio. Con la fecha, cada
|
||
# ciclo deja huella en el `git log` ⇒ se ve que el vigía sigue VIVO, no sólo que el último
|
||
# veredicto fue bueno.
|
||
{ echo "subcomandos sin driver · medido $(date -u +%FT%TZ)"
|
||
python3 scripts/vigia-subcomandos.py 2>&1; } > work/.subcomandos.tmp
|
||
mv work/.subcomandos.tmp docs/state/subcomandos.txt
|
||
echo " subcomandos.txt ✓ $(grep -m1 'TOTAL:' docs/state/subcomandos.txt || echo '0 inertes')"
|
||
|
||
{ echo "raíces sucias y symlinks al lab · medido $(date -u +%FT%TZ)"
|
||
python3 scripts/hydrate-profile.py --auditar-raices 2>&1; } > work/.raices.tmp
|
||
mv work/.raices.tmp docs/state/raices.txt
|
||
echo " raices.txt ✓ $(grep -m1 'ofensor' docs/state/raices.txt || echo '(sin resumen)')"
|
||
|
||
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ó"
|
||
|
||
# ── VIGÍA DE SERVICIOS (2026-09-12, SDD 30 §4a) ──────────────────────────────────────────────
|
||
# Contesta la pregunta que ni el grafo ni `vigia-sonames` contestan: no «¿está sellado?» ni
|
||
# «¿arranca el binario?» sino **«¿hay alguien que lo LEVANTE?»**. Un demonio puede estar sellado,
|
||
# con contenido, con todos sus sonames resueltos… y que ninguna imagen lo arranque nunca.
|
||
#
|
||
# Va al latido por la misma lección que dejó escrita `vigia-sonames` doce líneas más arriba —un
|
||
# vigía que hay que acordarse de invocar no se distingue de no tenerlo— y con más motivo: su
|
||
# entrada son DOS ficheros que cambian por separado (las recetas y `targets.toml`), así que la
|
||
# divergencia entre «declarado» y «habilitado» aparece sin que nadie toque el vigía.
|
||
#
|
||
# Corre PRIMERO su propio `--selftest`: si el guardián está roto, su silencio no es una respuesta.
|
||
{ echo "servicios por imagen · medido $(ts)"
|
||
echo "── autoprueba del vigía (el primer caso es el CONTROL, tiene que pasar)"
|
||
python3 scripts/targets.py --selftest 2>&1 | sed 's/^/ /'
|
||
for perf in $(python3 scripts/targets.py --lista 2>/dev/null | awk '{print $1}'); do
|
||
echo "── $perf"
|
||
python3 scripts/targets.py --services "$perf" 2>&1 | sed 's/^/ /'
|
||
done
|
||
} > work/.servicios.tmp 2>&1 && mv work/.servicios.tmp docs/state/servicios.txt \
|
||
&& echo " servicios.txt ✓ $(grep -c '^ ERROR' docs/state/servicios.txt) error(es), $(grep -c '^ AVISO' docs/state/servicios.txt) aviso(s)" \
|
||
|| echo " ⚠ vigía de servicios 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
|
||
# ── PONERSE AL DÍA ANTES DE COMMITEAR (2026-09-17) ────────────────────────────────────────────
|
||
# El latido commitea y empuja, pero nunca se ponía al día: en el hub eso no se notaba porque el
|
||
# árbol lo mantiene al día el agente que trabaja ahí. Al mover el latido a una caja donde NO hay
|
||
# nadie trabajando, el repo se queda atrás en el primer push ajeno y a partir de ahí **todos los
|
||
# ciclos fallan el push**, cada uno anotando «reintenta próximo ciclo» — un latido que late y no
|
||
# publica, para siempre.
|
||
#
|
||
# `--ff-only` a propósito, y nunca `--rebase`: este script corre en un árbol COMPARTIDO con otros
|
||
# agentes (regla 2 ter de CLAUDE.md, donde un `pull --rebase` se llevó un commit por delante).
|
||
# Fast-forward no reescribe nada: o adelanta limpio, o falla sin tocar el árbol y se anota.
|
||
if git pull --ff-only -q origin main 2>/dev/null; then
|
||
echo "==> repo al día (ff)"
|
||
else
|
||
echo "==> no se pudo adelantar por ff (árbol con cambios o historia divergente) — sigo igual"
|
||
fi
|
||
git add docs/state/subcomandos.txt docs/state/raices.txt docs/state/sonames.txt docs/state/servicios.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/subcomandos.txt docs/state/raices.txt docs/state/sonames.txt docs/state/servicios.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" \
|
||
|| { # ⚠ «reintenta próximo ciclo» NO era cierto y costó descubrirlo (2026-09-17): si el
|
||
# push se rechaza porque otro empujó, el ciclo siguiente se rechaza IGUAL —nadie
|
||
# rebasa nunca— y el commit del cron se queda local para siempre. Se encontró así en
|
||
# la caja: una rama divergente con un `estado: cosecha granja` atascado, y la cosecha
|
||
# diciendo «reintenta» cada media hora sin que nada cambiara.
|
||
echo "==> el push fue rechazado (otro empujó primero) — sincronizo"
|
||
if "$ROOT/scripts/git-sincronizar.sh" >/dev/null 2>&1; then
|
||
echo "==> estado rebasado y pusheado"
|
||
else
|
||
echo "==> NO pude publicar el estado — queda local. Verlo con: git log origin/main..HEAD"
|
||
fi; }; }
|
||
else
|
||
echo "==> sin cambios de estado este ciclo"
|
||
fi
|
||
fi
|
||
echo "── $(ts) cosecha-cron fin"
|