gioser

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 1506Shell 186Rust 107Markdown 983639 Definitions

⎇ main scripts/farm/cosecha-cron.sh Shell · 758 lines · 54.1 KB History raw

feat(granja): el censo de lo que FALTA entra al latido — el número que tiene que bajar befa94275 · Sergio ·

1#!/usr/bin/env bash
2# cosecha-cron.sh — el LATIDO de la granja: recoger + sembrar, en bucle, sin agente ni tokens.
3#
4# QUÉ HACE cada ciclo, por cada worker vivo en scripts/farm/.fleet:
5#   1. SIEMBRA (sube): rsync de código + recetas laptop→worker (el laptop es canónico; si autoré
6#      recetas nuevas —p.ej. openrc— el worker las empieza a moler sin que yo intervenga).
7#   2. RECOGE (baja): rsync del store sellado worker→laptop (CAS ⇒ merge seguro, sin conflicto).
8#   3. Regenera el GRAFO DE ESTADO (build-state{,-kde,-gnome,-cosmic}.json) desde el store ya
9#      cosechado, pasa los VIGÍAS (fuentes, contrato del kernel, y el audit de enlace estático con
10#      puerta diaria) y commitea SÓLO esos ficheros firmes. El `git log` de docs/state/ ES el avance
11#      que el humano ve y sigue (huecos, fallos, qué se selló entre dos ciclos).
12#
13# LO QUE **NO** HACE (a propósito): NO promueve incoming-kde→recipes/ canónico (esa es decisión de
14# clasificación humana — "el hub clasifica", ver granja-promote-colisiones) ni firma el índice. Sólo
15# captura al store del laptop y deja el veredicto para la mañana. Idempotente y re-corrible.
16#
17# INFINITO: vive en el crontab hasta que lo saques (`crontab -e`, borrar la línea cosecha-cron).
18# Robusto a worker-ausente: si el dead-man ya mató al worker (cola seca ⇒ €0), el paso 1/2 falla
19# suave, se loguea y igual regenera+commitea el estado local. NO relanza workers (eso gasta €;
20# es decisión explícita con farm-up).
21#
22# Uso:    scripts/farm/cosecha-cron.sh                 # un ciclo (lo que dispara el cron)
23#         */30 * * * * cd /home/sergio/takana && scripts/farm/cosecha-cron.sh >>work/cosecha-cron.log 2>&1
24# Env:    SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/takana), NO_COMMIT=1 (regenera pero no commitea)
25set -uo pipefail
26
27ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
28# Ruta absoluta a MÍ MISMO, capturada ANTES del `cd`: se usa para re-ejecutarse bajo el lock, y
29# un `$0` relativo dejaría de resolver en cuanto cambiamos de directorio.
30YO="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
31cd "$ROOT"
32
33# LOCK (2026-07-22): el latido ya no viene sólo del crontab — `latido.sh` lo lanza desde la sesión
34# porque el laptop arranca a veces con arje-zero y a veces con OpenRC, y cronie sólo existe en uno
35# de los dos. Con dos disparadores posibles, dos ciclos pueden solaparse: ambos hacen rsync sobre el
36# mismo store y, peor, `git commit`+`push` a la vez (index.lock, o un push que pisa al otro). El lock
37# va ACÁ y no en el llamador para que valga venga de donde venga: cron, latido o a mano.
38# -n = no esperar: si ya hay un ciclo corriendo, este sobra (el próximo tick recoge lo mismo).
39LOCKFILE="${LOCKFILE:-$ROOT/work/.cosecha-cron.lock}"
40mkdir -p "$(dirname "$LOCKFILE")"
41# ⚠ EL LOCK LO SOSTIENE EL fd, Y LOS HIJOS LO HEREDAN (medido 2026-09-08). Con `exec 9>` + `flock 9`
42# cualquier descendiente que se fugue retiene el lock DESPUÉS de que el script termine, y el próximo
43# que lo pida espera para siempre sin que nada falle. Pasó: dos `firefox` colgados de una caza de
44# bugs sobrevivieron al `kill` de su `bwrap` y dejaron a la granja hora y media sin poder compilar,
45# en silencio. `fuser -v <lock>` es lo que lo delata.
46#   El arreglo es `flock -o`, que cierra el fd antes de ejecutar ⇒ el lock lo sostiene SÓLO el
47# proceso `flock`, y ningún nieto puede prolongarlo. Como acá la sección crítica es el script
48# ENTERO, se re-ejecuta bajo `flock -o` en vez de ir poniendo `9>&-` comando por comando, que es
49# jugar a los topos y se olvida uno. `-E 77` distingue «el lock estaba ocupado» de «el trabajo
50# falló», que con el `9>` de antes no se podían separar.
51#   Comprobado con control positivo y negativo: sin `-o` el nieto retiene; con `-o` no. Y que la
52# exclusión mutua SIGUE funcionando, que es lo que un arreglo de locks puede romper sin avisar.
53#   ⚠ Ojo: `exec {L}>` (fd automático de bash) NO sirve — bash no lo marca close-on-exec. Medido.
54if [ -z "${COSECHA_CRON_CON_LOCK:-}" ]; then
55  export COSECHA_CRON_CON_LOCK=1
56  rc=0; flock -n -E 77 -o "$LOCKFILE" "$YO" "$@" || rc=$?
57  if [ "$rc" = 77 ]; then
58    echo "── $(date -u +%FT%TZ) cosecha-cron: ya hay un ciclo en curso ⇒ salgo (no me solapo)"
59    exit 0
60  fi
61  exit "$rc"
62fi
63
64SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
65REMOTE="${REMOTE:-/opt/takana}"
66FLEET="$ROOT/scripts/farm/.fleet"
67# ── SIEMBRA DE LA FLOTA FIJA ────────────────────────────────────────────────────────────────────
68# `.fleet` es estado de EJECUCIÓN (gitignored, lo reescribe el reaper cada ciclo); `flota-permanente`
69# es la parte versionada. Sin esto, un hub recién clonado nace con la flota vacía y la granja queda
70# desconectada EN SILENCIO: «flota vacía» cada 30 min, los artefactos del worker no vuelven, y nada
71# falla. Se unen por NOMBRE (el fijo gana) para que lo efímero de hcloud conviva sin duplicarse.
72FLEET_FIJA="$ROOT/scripts/farm/flota-permanente"
73if [ -s "$FLEET_FIJA" ]; then
74  # Los `|| true` NO son adorno: este script corre con `set -o pipefail`, y un `.fleet` ausente
75  # —que es EXACTAMENTE el caso que esta siembra existe para arreglar— hace fallar al `cat`, la
76  # tubería hereda ese estado y el `&& mv` no dispara. Resultado medido: el fichero se generaba
77  # bien y se quedaba sin mover, o sea que la siembra fallaba justo en el único caso que importa.
78  { grep -vE '^[[:space:]]*(#|$)' "$FLEET_FIJA" || true; cat "$FLEET" 2>/dev/null || true; } \
79    | awk 'NF && !visto[$1]++' > "$FLEET.seed" && mv "$FLEET.seed" "$FLEET"
80fi
81HAMMER="${TAKANA:-${HAMMER:-$ROOT/target/release/takana}}"
82# Workers efímeros reciclan IPs ⇒ no fijar known_hosts (hub-and-spoke, worker sin secretos).
83SSH="ssh -i $SSH_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=15"
84
85ts() { date -u +%FT%TZ; }
86echo "── $(ts) cosecha-cron arranca"
87
88# ── ⚠ LO QUE SE SIEMBRA TIENE QUE SER UN COMMIT ─────────────────────────────────────────────────
89# La siembra de abajo es `rsync ./` del ÁRBOL DE TRABAJO de este clon, sin `.git`: el worker
90# construye lo que hay en el disco de acá, no lo que hay en `origin/main`. Y desde el 2026-10-07
91# —cuando la publicación se desacopló del árbol y de la rama para que el latido no dejara de
92# publicar por un fichero sucio— ya no queda NADA que ponga esta rama al día. El desacople era
93# correcto; lo que se perdió sin que nadie lo notara es el único mecanismo que mantenía el árbol
94# sembrado igual a lo publicado.
95#
96# MEDIDO el 2026-10-15: este clon llevaba **106 commits y dos días** sin avanzar, y su árbol tenía
97# 13 recetas que no estaban en NINGÚN commit — un pin de `mirada*`/`shuma*` a otro commit de
98# tawasuyu y las deps medidas de `churay-metal`/`pata-*`. La granja las construyó y las selló: el
99# store del hub terminó con artefactos (churay-metal 7,7M, pata-centro 27M, mirada-compositor 18M)
100# cuya receta no existía en el repo, o sea irreproducibles por cualquiera que clonara. Y al revés:
101# `recipes/firmware-intel.toml`, retirada del hub ese mismo día, seguía viva acá, así que el grafo
102# publicaba un nodo `never` de una receta que ya no existe. Nadie se enteró porque lo único que se
103# decía era una línea del publicador —«la rama local queda detrás … lo publicado NO depende de
104# eso»— que es CIERTA sobre la publicación y muda sobre la siembra, que sí depende.
105#
106# Este guardián no arregla la deriva: la hace imposible de no ver, con nombres y en la primera
107# pantalla del log. Comparar CONTENIDO y no `git diff` a secas es deliberado: un fichero sin seguir
108# —y acá hay recetas sin commitear a propósito, de otro agente— sale en `git diff` como borrado y
109# daría un falso positivo eterno, que es como se mata a un guardián.
110deriva_de_la_siembra() {
111  # El alcance son las rutas ESCRITAS A MANO. `docs/state/` queda deliberadamente afuera aunque se
112  # siembre: lo regenera este mismo ciclo, así que un ciclo que no llegue a publicar dejaría al
113  # siguiente avisando de una deriva que no es deriva — y un aviso que sale siempre se deja de leer.
114  # Es el mismo bloqueo circular que `publicar-estado.sh` ya documentó con `cierres.json`.
115  local rutas="recipes crates scripts Cargo.toml Cargo.lock"
116  git fetch -q origin "+main:refs/siembra-chequeo" 2>/dev/null \
117    || { echo "   ⚠ no pude traer origin/main — no sé si lo que siembro es un commit"; return 0; }
118  local base=refs/siembra-chequeo
119  local detras ofensores=""
120  detras=$(git rev-list --count "HEAD..$base" 2>/dev/null || echo '?')
121  local f
122  for f in $(git diff --name-only "$base" -- $rutas 2>/dev/null); do
123    # Sin seguir y ausente de lo publicado ⇒ trabajo deliberado en vuelo, no deriva.
124    git cat-file -e "$base:$f" 2>/dev/null || continue
125    git show "$base:$f" 2>/dev/null | cmp -s - "$f" 2>/dev/null || ofensores="$ofensores $f"
126  done
127  # Y el otro lado de la misma moneda: una receta que sólo existe como fichero SIN SEGUIR en este
128  # clon se siembra igual, la granja la construye y la sella, y el store termina con un artefacto
129  # que ningún clon puede reproducir porque su receta no está en el repo. No es ⚠⚠ —editar acá y
130  # commitear después es un orden de trabajo legítimo— pero tiene que decirse con nombre y apellido,
131  # porque es exactamente así como `churay` llegó a estar sellado sin receta publicada (2026-10-15).
132  local sueltas
133  sueltas=$(git ls-files --others --exclude-standard -- recipes 2>/dev/null | grep -E '\.toml$' || true)
134  if [ -z "$ofensores" ]; then
135    echo "   siembra = origin/main en recetas y código ✓ (rama $detras commit(s) detrás, da igual: el árbol coincide)"
136  else
137    echo "   ⚠⚠ LO QUE SIEMBRO NO ES NINGÚN COMMIT — $(printf '%s\n' $ofensores | wc -l) fichero(s) difieren de origin/main:"
138    printf '        %s\n' $ofensores | head -8
139    [ "$(printf '%s\n' $ofensores | wc -l)" -gt 8 ] && echo "        … y más"
140    echo "   ⇒ el worker va a construir eso y a sellarlo. Publicalo o descartalo; el store no puede"
141    echo "     tener artefactos sin receta en el repo. Rama: $detras commit(s) detrás de origin/main."
142  fi
143  if [ -n "$sueltas" ]; then
144    echo "   · $(printf '%s\n' "$sueltas" | wc -l) receta(s) SIN COMMITEAR van a la granja — lo que sellen queda sin receta en el repo:"
145    printf '        %s\n' $sueltas | head -5
146  fi
147  # ── Y UN CASO QUE SÍ ROMPE EL HASH, no sólo la trazabilidad ─────────────────────────────────────
148  # Seis recetas traen `[source] dir` (atuq, firmware-metal, firmware-tigerlake, linux-firmware,
149  # os-release, wasi-sdk) y takana hashea ESE ÁRBOL ENTERO (`dir:<hash>` en takana-core/recipe.rs).
150  # ⇒ un fichero de más ahí dentro —el típico respaldo `X.antes-de-algo` que todos dejamos al editar
151  # en este clon— hace que el worker calcule OTRO ArtifactHash que el hub, y entonces no puede
152  # coincidir con él ni construyendo bien. Medido el 2026-10-15 con un dotfile vacío dentro de
153  # `recipes/firmware-metal/`: el hash pasó de `b3:51908c36…` a `b3:95418832…`. Es exactamente el
154  # apagón del PNG de `atuq` del 2026-09-06, por la otra puerta.
155  # ⚠ SIN `--exclude-standard`, y es un agujero MEDIDO del guardián anterior (2026-10-19). Con esa
156  # bandera git oculta lo que el `.gitignore` tapa — y takana hashea el árbol ENTERO, ignorado
157  # incluido. Resultado: `recipes/atuq/tools/__pycache__/rebrand.cpython-312.pyc`, invisible para
158  # `git status` y para este guardián, movía el hash de `atuq` de `fdb92aa3…` a `e42ccea7…` y los
159  # cuatro escritorios salían «clausura incompleta: atuq estado=debt». Es el mismo apagón que el
160  # commit `0f46c097` ya documentó («el hub sellaba algo que un clon limpio no reproduce»), y lo
161  # que lo hace peor es justamente que está ignorado: nada lo nombra en ningún sitio.
162  # ⇒ `--others` a secas lista también lo ignorado. Un fichero ignorado dentro de un `[source] dir`
163  # NO es inocuo, y es la única clase de basura que el repo no puede ver por sí mismo.
164  local dentro
165  dentro=$(git ls-files --others -- recipes 2>/dev/null | awk -F/ 'NF>2' || true)
166  if [ -n "$dentro" ]; then
167    echo "   ⚠⚠ $(printf '%s\n' "$dentro" | wc -l) fichero(s) SIN SEGUIR dentro del árbol de una receta —"
168    echo "      eso cambia su ArtifactHash y el worker deja de poder coincidir con el hub:"
169    printf '        %s\n' $dentro | head -6
170  fi
171}
172deriva_de_la_siembra
173
174if [ ! -s "$FLEET" ]; then
175  echo "   flota vacía (scripts/farm/.fleet) — nada que cosechar este ciclo"
176else
177  while read -r name ip; do
178    [ -n "${name:-}" ] || continue
179    echo "==> $name ($ip)"
180    # 1. SIEMBRA: código + recetas laptop→worker (--delete: el laptop manda; nunca toca work/store/target).
181    # ⚠ LA EXCLUSIÓN DE PNG VA ANCLADA, Y NO ES ESTILO (2026-09-06). Antes decía `--exclude '*.png'`,
182    # sin anclar, o sea PNG en CUALQUIER sitio — incluido `recipes/`. Dos consecuencias, las dos malas:
183    #
184    #   a) rsync PROTEGE DE BORRADO lo que excluye. Un directorio cuyo único resto es un `.png` no se
185    #      puede vaciar y por lo tanto no se puede borrar NUNCA, aunque el hub lo haya eliminado.
186    #      Medido: `recipes/atuq/extension/atuq128.png` sobrevivió a un rename hecho en el hub y el
187    #      log de la siembra repetía `cannot delete non-empty directory: recipes/atuq/extension`.
188    #   b) Un PNG NUEVO de una receta tampoco viaja. Y hay recetas cuyo `[source] dir` tiene PNG
189    #      dentro: los 9 iconos de `recipes/atuq/branding/icons/`.
190    #
191    # Como takana SÍ hashea el árbol de un `[source] dir` (`recipe.rs`, `dir:<hash>`), el efecto no es
192    # silencioso —bendito sea—: el worker calcula OTRO ArtifactHash. Medido el mismo día,
193    # `atuq` daba `b3:f2960991` en el hub y `b3:b33e81a5` en el worker por ESE único fichero de más.
194    # O sea que el worker no podía coincidir con el hub ni construyendo bien.
195    #
196    # Lo que se quería ahorrar eran las capturas: `docs/evidencia/` (4,4 M) y el PNG suelto de la raíz
197    # (1 M). Eso se excluye por RUTA. Los PNG de `recipes/` viajan, que es lo correcto: son fuente.
198    if rsync -az --delete -e "$SSH" \
199        --exclude /work --exclude /store --exclude '/store-*' --exclude /target \
200        --exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
201        --exclude '/docs/evidencia/' --exclude '/*.png' --exclude '/content*' \
202        ./ "root@$ip:$REMOTE/" 2>&1 | tail -2; then
203      echo "   siembra ✓"
204    else
205      echo "   ⚠ siembra falló (worker ausente/idle-muerto?) — sigo con el siguiente"
206      continue
207    fi
208    # 2. RECOGE: store sellado worker→laptop (CAS, merge seguro).
209    # ── ⚠ EXCLUIR LO YA PODADO, O LA POda SE DESHACE SOLA ─────────────────────────────────────
210    # El worker NO se poda nunca, así que conserva los artefactos superados y este rsync los
211    # devolvía enteros. Con el latido cada 30 min, una poda de 24 G quedaba deshecha en menos de
212    # una hora: medido el 2026-08-07, tres podas seguidas borraron LOS MISMOS 362 artefactos.
213    #
214    # El mismo arreglo está en `farm-sync.sh` — y ahí estuvo el error de método: lo puse allí, di el
215    # bucle por cerrado, y la churn siguió porque **hay DOS rutas que bajan el store** y el latido
216    # usa ésta. Arreglar una de dos copias y declarar victoria es peor que no arreglar: el síntoma
217    # se atenúa lo justo para dejar de mirar. Si aparece una tercera copia, va con el mismo exclude.
218    #
219    # ── 2026-08-09: EL STORE YA NO BAJA. BAJA EL MANIFIESTO ───────────────────────────────────
220    # El store se mudó al VOLUMEN de la granja y el laptop se quedó con lo que la granja no puede
221    # rehacer. Seguir bajando el store acá desharía esa mudanza cada 30 minutos — la misma forma
222    # exacta del bucle de churn que este bloque documenta arriba, y la razón por la que el exclude
223    # de abajo dejó de alcanzar: ya no se trata de filtrar QUÉ baja, sino de que no baja.
224    #
225    # Lo que el hub sí necesita es SABER qué hay sellado, para que el grafo de estado no reporte
226    # `never` sobre artefactos que existen. Eso son kilobytes: la lista de nombres. Se acumula en
227    # el manifiesto (nunca se pisa) porque el volumen es una sola foto y el hub recuerda todas.
228    if $SSH "root@$ip" "ls $REMOTE/store 2>/dev/null | grep -E '^[0-9a-f]{64}-'" \
229         > "$ROOT/work/.sellados-worker" 2>/dev/null && [ -s "$ROOT/work/.sellados-worker" ]; then
230      N_W=$(wc -l < "$ROOT/work/.sellados-worker")
231      cat "$ROOT/work/farm-sellados.txt" "$ROOT/work/.sellados-worker" 2>/dev/null \
232        | grep -E '^[0-9a-f]{64}-' | sort -u > "$ROOT/work/farm-sellados.txt.new"
233      mv "$ROOT/work/farm-sellados.txt.new" "$ROOT/work/farm-sellados.txt"
234      rm -f "$ROOT/work/.sellados-worker"
235      echo "   manifiesto ✓ (worker: $N_W · total: $(wc -l < "$ROOT/work/farm-sellados.txt"))"
236    else
237      echo "   ⚠ no pude leer el manifiesto del worker — sigo"
238    fi
239    # ── ⚠ EL DISCO DEL WORKER, QUE NADIE MIRABA ─────────────────────────────────────────────────
240    # El latido informaba del worker sólo por su manifiesto —cuántos artefactos tiene— y nunca por
241    # su disco, así que la caja se llenó sin que ningún log lo dijera: medido el 2026-10-15, **90 %
242    # (20 G libres de 196)** con una tanda en vuelo. Y «el worker no se poda nunca» es una regla
243    # real, pero su motivo es que la COSECHA no deshaga la poda (su rsync devolvía el store entero),
244    # no que el store no deba podarse: `scripts/store-gc.sh` existe para eso y trae el mecanismo que
245    # evita ese churn —`work/store-gc-superados.txt`, registro acumulado de lo que NO debe volver—.
246    # Nunca se había corrido allá. Al hacerlo: 465 artefactos superados, **18 G → 43 G libres**.
247    #
248    # ⇒ un número por ciclo, y el comando sólo cuando hace falta. El suelo son 25 G porque un árbol
249    # de fuentes del monorepo cuesta ~2,7 G y una tanda de seis encadenadas no puede quedarse sin
250    # sitio a mitad: el disco lleno NO se lee como disco lleno, se lee como receta rota (y eso ya
251    # costó ocho recetas KDE marcadas «deuda» sin tener nada roto).
252    libres_w=$($SSH "root@$ip" "df -Pm /opt/takana 2>/dev/null | awk 'NR==2{print \$4}'" 2>/dev/null || echo "")
253    # ⚠ Se valida que sea un ENTERO, y no es paranoia de estilo: con basura ahí el `[ -lt ]` falla,
254    # el `elif` sale falso y la ejecución cae en el `else`, o sea que un `df` ilegible se informaría
255    # como «✓ libres». Un control que falla hacia «todo bien» es exactamente el que no sirve.
256    case "$libres_w" in (*[!0-9]*|"") libres_w="" ;; esac
257    if [ -z "$libres_w" ]; then
258      echo "   ⚠ no pude leer el disco del worker (df ilegible o caja ausente)"
259    elif [ "$libres_w" -lt 25600 ]; then
260      echo "   ⚠⚠ disco del worker: $((libres_w/1024)) G libres — bajo el suelo de 25 G."
261      echo "      Un árbol de fuentes del monorepo cuesta ~2,7 G: una tanda se queda sin sitio a"
262      echo "      mitad y el fallo se lee como receta rota. El GC del store mide antes de borrar:"
263      echo "        ssh root@$ip 'cd /opt/takana && STORE=./store bash scripts/store-gc.sh'"
264      echo "        …y con --aplicar borra SÓLO los superados (los huérfanos hay que quererlos)."
265    else
266      echo "   disco del worker: $((libres_w/1024)) G libres ✓"
267    fi
268    # ⚠ Lo que NO cubre esto: los artefactos nuevos quedan SÓLO en el volumen hasta que alguien
269    # haga una pasada de respaldo. El volumen tiene borrado protegido, pero es UNA copia.
270  done < "$FLEET"
271fi
272
273# 2.5 REAPER HUB-SIDE (2026-07-23). El dead-man DEL WORKER probó FRÁGIL: un worker quedó 3.5h idle
274# quemando € porque su `/etc/takana-deadman.env` no tenía token válido ⇒ el dead-man llegaba a
275# matarse pero salía 1 "sin HCLOUD_TOKEN" cada tick. La garantía "un vps idle es inadmisible" NO
276# puede depender de que cada worker se auto-provisione bien un token (falla en silencio). El HUB sí
277# tiene un token que funciona, y este latido corre cada 30 min aunque no haya sesión (setsid) ⇒ el
278# hub es la AUTORIDAD de vida del worker. Es defensa en profundidad: el dead-man del worker cubre
279# "hub caído"; esto cubre "dead-man del worker roto". Un worker sin trabajo activo REAPER_MAX ciclos
280# seguidos se BORRA desde acá, con el MISMO blindaje que el dead-man (label role=takana-worker;
281# gioser no tiene label ⇒ jamás pasa, ni por accidente).
282REAPER_MAX="${REAPER_MAX:-2}"   # 2 ciclos × 30 min ≈ 1 h idle, igual que el dead-man del worker
283if [ -s "$FLEET" ] && command -v hcloud >/dev/null 2>&1; then
284  mkdir -p "$ROOT/work/.reaper"; sobreviven=""
285  while read -r name ip; do
286    [ -n "${name:-}" ] || continue
287    # LISTA NEGRA (capa 1 de 2, igual que el dead-man): gioser JAMÁS se toca, pase lo que pase.
288    # Segunda capa = el chequeo de label abajo. gioser no tiene label Y no matchea worker de la
289    # granja ⇒ imposible que muera por acá. "Ahí está nuestra vida" (2026-07-23).
290    case "$name" in *gioser*) echo "==> reaper: $name en LISTA NEGRA ⇒ intocable"; sobreviven="${sobreviven}${name} ${ip}
291"; continue;; esac
292    strike_f="$ROOT/work/.reaper/$name"
293    # ¿el server siquiera existe? Si ya no está en hcloud (borrado a mano o por un ciclo previo),
294    # NO cuesta € y no debe quedar en .fleet para siempre. Se dropea (no entra a `sobreviven`).
295    if ! hcloud server describe "$name" -o format='{{.Name}}' >/dev/null 2>&1; then
296      # ⚠ «No está en hcloud» son DOS casos muy distintos, y antes se trataban igual (2026-09-08):
297      #   · un worker PERMANENTE que no es de Hetzner — hoy el LXC prestado `dev.gioser.net`, que
298      #     no cuesta € ⇒ NO HAY NADA QUE SEGAR, y sacarlo de .fleet desconecta la granja EN
299      #     SILENCIO: la cosecha pasa a decir «flota vacía» y los artefactos del worker dejan de
300      #     volver, sin que nada falle. Es el mismo fallo mudo que este fichero ya tuvo.
301      #   · un server efímero ya borrado a mano ⇒ ése sí sobra en .fleet para siempre.
302      # Se distinguen preguntándole a la MÁQUINA, no al nombre. Antes el LXC sólo se salvaba porque
303      # su nombre contiene la subcadena «gioser» y caía en la lista negra de arriba — una lista que
304      # existe para proteger al HUB de Hetzner y no tiene nada que ver con él. Medido: el mismo host
305      # llamado `pruebasia-lxc` era expulsado de .fleet en el primer ciclo.
306      if $SSH root@"$ip" true 2>/dev/null; then
307        echo "==> reaper: $name no es de hcloud pero RESPONDE ⇒ worker permanente, se queda (€0)"
308        sobreviven="${sobreviven}${name} ${ip}
309"
310      else
311        echo "==> reaper: $name no está en hcloud y no responde ⇒ lo saco de .fleet"; rm -f "$strike_f"
312      fi
313      continue
314    fi
315    if $SSH root@"$ip" 'pgrep -f "hammer build|campana-deuda|farm-worker-loop" >/dev/null 2>&1'; then
316      echo 0 > "$strike_f"; sobreviven="${sobreviven}${name} ${ip}
317"; continue
318    fi
319    strikes=$(( $(cat "$strike_f" 2>/dev/null || echo 0) + 1 )); echo "$strikes" > "$strike_f"
320    echo "==> reaper: $name SIN trabajo activo — strike $strikes/$REAPER_MAX"
321    if [ "$strikes" -lt "$REAPER_MAX" ]; then sobreviven="${sobreviven}${name} ${ip}
322"; continue; fi
323    # BLINDAJE (igual que el dead-man): sólo se borra un server con label role=takana-worker.
324    if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
325      echo "   ☠ IDLE $strikes ciclos ⇒ el HUB borra $name (label verificado; gioser protegido)"
326      if hcloud server delete "$name" >/dev/null 2>&1; then rm -f "$strike_f"
327      else echo "   ⚠ delete falló — sobrevive, reintenta próximo ciclo"; sobreviven="${sobreviven}${name} ${ip}
328"; fi
329    else
330      echo "   ⛔ $name SIN label hammer-worker ⇒ NO se borra (protegido)"; sobreviven="${sobreviven}${name} ${ip}
331"
332    fi
333  done < "$FLEET"
334  printf '%s' "$sobreviven" | grep -v '^[[:space:]]*$' > "$FLEET.tmp" 2>/dev/null; mv "$FLEET.tmp" "$FLEET" 2>/dev/null || true
335fi
336
337# 2.7 PODA DE `work/sources` (2026-08-31). Cuarto vigía, y el único que no emite veredicto: su
338# salida es que el disco se quede plano.
339#
340# POR QUÉ EXISTE: ese día el volumen del store (`/dev/sdb`, 246 G) estaba al **97%, 8,8 G libres**,
341# y el gordo NO era el store — eran **79 G de `work/sources`**, 106 árboles con el `target/` de
342# cargo y los `.o` dentro (el árbol de fuentes es también el directorio de compilación). Nada en la
343# granja podaba eso: `store-gc.sh` mira el store, la poda de caché de CI mira la caché de CI, y este
344# tercer montón crecía sin dueño desde que el store se mudó al volumen. Se recuperaron 70 G.
345#
346# POR QUÉ ES SEGURO: no es una caché. Los dos caminos de `takana-build/src/fetch.rs` borran el árbol
347# y lo re-extraen en CADA build, sin una sola rama que reutilice ⇒ un árbol viejo no acelera nada y
348# borrarlo no cuesta ni la re-extracción. Lo único que se pierde es el post-mortem del último build
349# de esa receta, y por eso el suelo son 24 h y no cero.
350#
351# CADA CICLO, no con puerta diaria como el audit: el `find` sobre ~100 directorios es instantáneo y
352# el `rm` cuesta en proporción a la basura que haya. Con puerta diaria la basura de un día entero de
353# granja llegaría junta, que es justo lo que se quiere evitar.
354#
355# El script toma `work/.farm-build.lock` por dentro y con espera acotada: borrar el árbol que un
356# `bwrap` está compilando es el ADR 0012 en su forma más directa, y el mtime no distingue "viejo" de
357# "build lento". Si hay algo en vuelo, salta el ciclo y sale 0.
358if [ -x "$ROOT/scripts/poda-fuentes.sh" ]; then
359  "$ROOT/scripts/poda-fuentes.sh" --aplicar || echo "   ⚠ poda de fuentes falló (rc=$?) — sigo"
360fi
361
362# 3. Regenerar el grafo de estado desde el store ya cosechado (usa el takana del laptop, que tiene
363# el subcomando `hash`). Barato (~14s cada uno). El corpus canónico y el frente KDE, por separado.
364echo "==> regenerando grafo de estado"
365if [ -x "$HAMMER" ]; then
366  scripts/build-state.py >/dev/null 2>&1 && echo "   build-state.json ✓" || echo "   ⚠ build-state.py falló"
367  scripts/build-state.py --kde >/dev/null 2>&1 && echo "   build-state-kde.json ✓" || echo "   ⚠ build-state.py --kde falló"
368  # GNOME faltaba acá (2026-07-27): el latido regeneraba base+KDE y NUNCA el grafo GNOME, así que
369  # build-state-gnome.json envejecía en silencio y reportaba `never` sobre recetas selladas hacía
370  # días (gobject-introspection, toda la onda 2). Un grafo viejo miente con la misma cara que uno
371  # fresco — por eso lo regenera el latido y no la mano.
372  scripts/build-state.py --gnome >/dev/null 2>&1 && echo "   build-state-gnome.json ✓" || echo "   ⚠ build-state.py --gnome falló"
373  # COSMIC (2026-08-03), por la MISMA razón por la que se agregó GNOME arriba: un frente que no
374  # regenera el latido envejece en silencio, y un grafo viejo miente con la misma cara que uno fresco.
375  scripts/build-state.py --cosmic >/dev/null 2>&1 && echo "   build-state-cosmic.json ✓" || echo "   ⚠ build-state.py --cosmic falló"
376  # wlr/sway (2026-08-26), TERCERA vez que cae el mismo cable. El flag `--wlr` se escribió el día que
377  # se abrió el frente, y su propio comentario en build-state.py dice «sin esto el frente es INVISIBLE
378  # para el khipu» — pero la línea nunca se agregó ACÁ. Resultado medido: build-state-wlr.json se
379  # quedó congelado el 2026-08-09 diciendo `escritorio-sway 121/121`, y 17 días después la realidad
380  # era 108/122: freetype, make, python3 y libpng-pic se re-hashearon en el medio y arrastraron a 14
381  # dependientes a deuda. Nadie lo vio porque el número que todo el mundo mira estaba perfecto.
382  # Un grafo viejo miente con la misma cara que uno fresco — y el que nadie regenera miente MÁS,
383  # porque envejece en la dirección optimista: sólo puede sobreestimar lo que hay sellado.
384  scripts/build-state.py --wlr >/dev/null 2>&1 && echo "   build-state-wlr.json ✓" || echo "   ⚠ build-state.py --wlr falló"
385  # keystones y duplicados (2026-09-08). CUARTA vez que cae el mismo cable: los comentarios de
386  # arriba lo cuentan ya para GNOME (2026-07-27), COSMIC (2026-08-03) y wlr/sway (2026-08-26), y
387  # estos dos se habían quedado congelados el 2026-09-05 — TRES DÍAS. Con un agravante que los
388  # otros no tenían: `keystones` es el fichero que responde «¿qué construyo después?», así que
389  # rancio no se limita a envejecer, DESINFORMA. Medido: decía «1 nudo en deuda, camino crítico
390  # corpus/firefox → corpus/atuq» con los dos sellados hacía horas. Un grafo viejo miente con la
391  # misma cara que uno fresco, y éste además manda a trabajar en lo que ya está hecho.
392  scripts/yupana.py keystones  >/dev/null 2>&1 && echo "   keystones.json ✓"  || echo "   ⚠ yupana keystones falló"
393  scripts/yupana.py duplicados >/dev/null 2>&1 && echo "   duplicados.json ✓" || echo "   ⚠ yupana duplicados falló"
394  # Cola DERIVADA del grafo (P4): el orden de masticado por imagen, en ondas topológicas. No lanza
395  # ningún build — sólo deja escrito qué conviene moler primero, para que la cola deje de ser una
396  # lista escrita a mano. Barato (lee JSON, no hashea).
397  # NO se traga la salida entera: drenar.py sale 1 cuando alguna imagen quedó SIN MEDIR (una cola
398  # nueva sin su línea en GRAFOS), y con `>/dev/null 2>&1` eso llegaba al log como un "falló" mudo
399  # que no dice cuál. Se filtran las líneas ⚠, que son las únicas accionables.
400  if drenaje_out="$(scripts/drenar.py --todos 2>&1)"; then
401    echo "   drenaje.json ✓"
402  else
403    echo "   ⚠ drenar.py falló"
404    echo "$drenaje_out" | grep '⚠' | sed 's/^/   /'
405  fi
406  # VIGÍA DE FUENTES (ADR 0013 §3). Desde que el mirror propio se consulta ANTES que upstream,
407  # `takana build` deja de avisar cuando una URL de terceros muere: sirve los bytes del mirror y
408  # sigue. Eso es lo correcto para construir y sería CIEGO sin este contrapeso — las URLs se irían
409  # muriendo una a una y el corpus resultaría irreconstruible el día que falte el mirror, con todo
410  # en verde hasta ese momento. Igual que el grafo de wlr, que pasó 17 días anunciando un 121/121
411  # que ya era falso: lo que nadie refresca envejece hacia el optimismo.
412  # No descarga: pide cabeceras. Primera corrida (2026-08-26): 8 muertas de 1167, y TRES de ellas
413  # —busybox, freetype, freetype-shared— selladas y en uso.
414  scripts/fuentes/fuentes-vigia.sh >/dev/null 2>&1 && echo "   fuentes-vigia.json ✓" || echo "   ⚠ fuentes-vigia falló"
415  # CONTRATO DE CAPACIDADES DEL KERNEL (SDD 25 §4.ter). Mismo cable que el vigía de fuentes y por la
416  # misma razón: el gate existía y NADIE lo corría. Un kernel reconstruido sin `MEMCG` volvería a
417  # pasar inadvertido, y ese fallo **no se ve del lado del desarrollo** —el kernel de esta máquina sí
418  # lo trae—, sólo comprobando el artefacto. Acá el veredicto queda en docs/state/ y el `git log` lo
419  # muestra solo, como el resto del khipu.
420  # Se escribe por temporal y sólo se mueve si tiene contenido: `contract` sale != 0 cuando el
421  # contrato NO se cumple —y ese rojo es justo lo que hay que guardar—, pero un fichero VACÍO se
422  # leería como «nada que objetar», que es el fallo de CLAUDE.md §3.
423  "$HAMMER" --store ./store kernel contract --sealed > work/.kernel-contract.tmp 2>&1
424  if [ -s work/.kernel-contract.tmp ]; then
425    mv work/.kernel-contract.tmp docs/state/kernel-contract.txt
426    echo "   kernel-contract.txt ✓ $(grep -m1 '^resumen' docs/state/kernel-contract.txt || echo '(sin resumen)')"
427  else
428    rm -f work/.kernel-contract.tmp
429    echo "   ⚠ kernel contract no produjo salida — NO piso el veredicto anterior"
430  fi
431  # AUDIT DE ENLACE ESTÁTICO (`link = "static"`), CON PUERTA DIARIA. Tercer cable de la misma
432  # familia que el vigía de fuentes y el contrato del kernel, y por la misma razón exacta: el gate
433  # existía desde julio, nadie lo re-corría, y el 2026-08-31 el "MIENTEN: 0" con el que se había
434  # cerrado el frente resultó ser 4 — dwarves, htop, wlr-randr y gtk4, todas selladas DESPUÉS del
435  # cierre. Un frente que se cierra en cero y no se vuelve a medir no se queda en cero.
436  # Lo que costaba ese silencio, medido ese mismo día: `bash` —el shell de los perfiles base, cli y
437  # dos escritorios— no ARRANCABA fuera del sandbox, y las cuatro apps GTK4 del corpus
438  # (hammer-edit incluida) segfalteaban en `gtk_init`. Todo sellado y en verde.
439  #
440  # PUERTA DIARIA, no cada ciclo: el barrido recorre los ~750 sellados leyendo cada ejecutable con
441  # `file`+`readelf` y tarda ~8 min de CPU en 4 vCPU que además comparte con el worker. Cada 30 min
442  # sería más de un cuarto de core para siempre vigilando algo que sólo cambia cuando se re-sella
443  # una receta. Con la puerta, es 8 min al día.
444  #
445  # El sello es un LIMITADOR DE RITMO, no una marca de éxito ⇒ se toca en cuanto el barrido termina,
446  # salga como salga. Si sólo se tocara al acertar, un fallo que tarde los 8 min se repetiría cada
447  # 30 y el cable pasaría de vigilancia a sangría. El veredicto anterior está protegido aparte, por
448  # el mismo temporal-no-vacío del contrato del kernel.
449  # Para forzarlo a mano: `rm work/.static-audit.sello` y esperar al próximo ciclo.
450  #
451  # NO toma `work/.farm-build.lock` a propósito: sólo LEE el store, y retener el lock 8 min pararía
452  # la granja entera. `nice -n 19` porque la prioridad la tiene construir, no auditar.
453  # ── LOS DOS VIGÍAS NUEVOS (2026-09-12) ───────────────────────────────────────────────────────
454  # Van FUERA de la puerta diaria porque son baratos —medidos: 4 s y 22 s— y dentro del `if` del
455  # sello sólo correrían una vez al día sin motivo.
456  #
457  # El argumento para cablearlos es el mismo que dejó escrito `vigia-sonames` tres líneas más
458  # abajo, y que costó `libstdc++.so.6` roto en los cuatro perfiles: **un vigía que hay que
459  # acordarse de invocar no se distingue de no tenerlo**. Los dos nacieron hoy encontrando cosas
460  # reales —46 binarios inertes por falta de driver; 4 comandos de bzip2 apuntando a `/out`— y las
461  # dos las encontré a mano. Eso no se repite solo.
462  #
463  # Dejan fichero en `docs/state/` con la FECHA DE MEDICIÓN por delante, por la misma razón que el
464  # static-audit: con el frente en verde el texto es constante, `git diff --cached --quiet` no vería
465  # cambio, y dentro de tres meses el fichero sería indistinguible de uno rancio. Con la fecha, cada
466  # ciclo deja huella en el `git log` ⇒ se ve que el vigía sigue VIVO, no sólo que el último
467  # veredicto fue bueno.
468  { echo "subcomandos sin driver · medido $(date -u +%FT%TZ)"
469    python3 scripts/vigia-subcomandos.py 2>&1; } > work/.subcomandos.tmp
470  mv work/.subcomandos.tmp docs/state/subcomandos.txt
471  echo "   subcomandos.txt ✓ $(grep -m1 'TOTAL:' docs/state/subcomandos.txt || echo '0 inertes')"
472
473  # ── EL CENSO DE APPLETS, QUE EXISTÍA Y NADIE CORRÍA ──────────────────────────────────────────
474  # `busybox-censo-proveedores.py` mide, contra los artefactos del store, quién provee de verdad
475  # cada applet de busybox — y trae un `--guardian` que sale ≠0 si alguno PIERDE el proveedor que
476  # el TSV registra. Estaba escrito para esto y **nunca se cableó**: la medición sólo existía
477  # cuando alguien la lanzaba a mano, o sea casi nunca.
478  #
479  # Medido el 2026-10-16 al correrlo: de los 401 applets, 215 ya tienen dueño real y **47 los
480  # provee SÓLO busybox**. Y el titular no es «faltan recetas» —faltan dos: `getty`/`cttyhack` y
481  # el `nslookup`— sino que **`arje-zero` (PID 1) y `brush` (el shell, `sh`/`ash`/`hush`) están
482  # SELLADOS y en NINGÚN perfil**, igual que `busybox` y `chrony`. O sea que la imagen toma su init
483  # del piso y su `/bin/sh` de busybox, y la clausura no contiene ninguna de las dos piezas más
484  # básicas del sistema. Es el censo del piso (`docs/state/piso.json`) por el otro lado.
485  #
486  # Una línea por ciclo, y el detalle sólo cuando el guardián se queja: lo que el número tiene que
487  # hacer es BAJAR, y para eso hay que verlo todos los días.
488  if python3 scripts/busybox-censo-proveedores.py --guardian > work/.applets.salida 2>&1; then
489    echo "   applets ✓ $(grep -m1 'SÓLO lo provee busybox' work/.applets.salida \
490         | sed 's/^ *//; s/  */ /g' || echo '(sin resumen)')"
491  else
492    echo "   ⚠⚠ un applet PERDIÓ el proveedor que el TSV registra — el reemplazo dejó de instalarlo:"
493    grep -E 'PERDIÓ|perdió|✗' work/.applets.salida | head -6 | sed 's/^/      /'
494  fi
495  rm -f work/.applets.salida
496
497  # ── EL CENSO DE LO QUE FALTA PARA USARLA (2026-10-19) ────────────────────────────────────────
498  # Las otras métricas contestan «¿está completo lo que ya nombramos?» — y las tres pueden estar en
499  # verde (1158/1158, deuda 0, clausura 10 de 10) con la distro mostrando cuadraditos en chino.
500  # Ésta contesta la otra pregunta, y su número tiene que BAJAR: el `git diff` de
501  # `docs/state/distro-huecos.json` ES el avance del frente de producto.
502  #
503  # Va sin puerta diaria porque cuesta ~2 s y su valor es ir al ritmo del catálogo: el día que
504  # alguien declare un paquete, la línea lo refleja en el ciclo siguiente. Y sale SIEMPRE, incluso
505  # sin cambios, porque un contador que sólo aparece cuando se mueve no se usa para decidir.
506  if python3 scripts/censo-distro.py --json work/.distro.tmp > work/.distro.salida 2>&1 \
507     && [ -s work/.distro.tmp ]; then
508    mv work/.distro.tmp docs/state/distro-huecos.json
509    echo "   distro-huecos.json ✓ $(grep -m1 'resumen:' work/.distro.salida | sed 's/^ *resumen: //')"
510    # Lo que YA está cubierto por otro nombre y lo que tiene muro medido: dos listas cortas que
511    # cambian el presupuesto de un hueco, y por eso valen más que el total.
512    sed -n '/CAPACIDAD YA CUBIERTA/,/^   —/p;/MUROS MEDIDOS/,$p' work/.distro.salida \
513      | grep -E '^        ' | head -6 | sed 's/^      /     /'
514  else
515    echo "   ⚠ censo-distro falló — NO piso el informe anterior"
516    tail -2 work/.distro.salida 2>/dev/null | sed 's/^/      /'
517  fi
518  rm -f work/.distro.tmp work/.distro.salida
519
520  { echo "raíces sucias y symlinks al lab · medido $(date -u +%FT%TZ)"
521    python3 scripts/hydrate-profile.py --auditar-raices 2>&1; } > work/.raices.tmp
522  mv work/.raices.tmp docs/state/raices.txt
523  echo "   raices.txt ✓ $(grep -m1 'ofensor' docs/state/raices.txt || echo '(sin resumen)')"
524
525  # ── VIGÍA DE RÍO ARRIBA (2026-10-07, SDD 33 §2.1) ────────────────────────────────────────────
526  # Contesta lo que `fuentes-vigia` NO contesta. Aquél pregunta si la fuente SIGUE VIVA; ésta
527  # pregunta si hay **algo más nuevo que lo que tenemos clavado**. Se confunden y son las dos
528  # mitades: una fuente viva puede llevar dos años sin tocarse, y una que se movió ayer puede
529  # desaparecer mañana.
530  #
531  # Se cablea por la lección que este guion ya tiene escrita dos veces —**un vigía que hay que
532  # acordarse de invocar no se distingue de no tenerlo**—: nació el 2026-09-23 con el SDD 33,
533  # corrió UNA vez a mano, y el 2026-10-07 su informe seguía siendo el de aquel día. En esos
534  # 14 días las recetas con un tag más nuevo pasaron de 462 a 479 y nadie lo supo.
535  #
536  # ⚠ PUERTA DIARIA, y acá no es por coste de CPU sino por EDUCACIÓN: son 660 `git ls-remote`
537  # contra servidores ajenos (607 en github). A 30 min serían ~31.000 consultas al día, que no es
538  # vigilar sino martillear. El SDD 33 le pone un día de reloj y un día es.
539  #
540  # NO modifica ninguna receta, y eso es política, no pereza: una versión nueva de upstream puede
541  # cambiar el sistema de build, las banderas, las deps y el resultado (SDD 33 §3.2 — el kernel
542  # 7.3 es el ejemplo). Propone; quien decide aplica con `bump-repo.py` o `takana pin` y construye.
543  # ── CIERRE DE PERFILES (2026-10-10, SDD 33 §4) ───────────────────────────────────────────────
544  # El §4 decide rearmar los medios **por el CIERRE, no por el calendario**, y para eso hacía falta
545  # un número que no existía: el hash de la clausura de cada perfil. Esto lo calcula sin construir
546  # nada —lee los grafos que las líneas de arriba acaban de regenerar— así que va aquí y no en una
547  # puerta diaria: cuesta menos de un segundo y su valor es justamente ir al ritmo del grafo.
548  #
549  # El `git log` de `docs/state/cierres.json` ES la señal «este perfil se movió ⇒ su imagen quedó
550  # rancia». Hoy no hay imágenes publicadas con las que comparar, así que esta mitad es la que se
551  # puede tener: saber QUÉ cambió sin rearmar para averiguarlo.
552  #
553  # ⚠ `--contra` apunta al informe ANTERIOR (el que todavía está en disco) para que la salida diga
554  # qué se movió respecto de la última cosecha. Se lee antes de sobrescribirlo, y por eso el fichero
555  # temporal: escribir directo sobre `cierres.json` borraría la base de la comparación.
556  if python3 scripts/cierre-perfiles.py --contra docs/state/cierres.json \
557       --json work/.cierres.tmp > work/.cierres.salida 2>&1 && [ -s work/.cierres.tmp ]; then
558    mv work/.cierres.tmp docs/state/cierres.json
559    echo "   cierres.json ✓ $(sed -n '1s/^== //;1s/ ==$//;1p' work/.cierres.salida)"
560    grep -c "SE MOVIÓ" work/.cierres.salida >/dev/null 2>&1 && \
561      sed -n '/SE MOVIÓ/p' work/.cierres.salida | sed 's/^   /      /' | head -6
562
563    # ── Y LO QUE HAY QUE HACER CON ESE NÚMERO ────────────────────────────────────────────────────
564    # «3 de 10 con clausura completa» es una medición, no un encargo, y así estuvo durante días sin
565    # que nadie la convirtiera en trabajo. Medido el 2026-10-15: los SIETE perfiles incompletos lo
566    # estaban por las MISMAS 7 recetas en deuda del corpus —`wireplumber` llevaba 8 días, desde que
567    # una dep le movió el hash el 07-oct—, y ninguna tenía quien la construyera: el bucle del worker
568    # (`takana-farm.service`) sólo muele `recipes/incoming/`, y la deuda del CORPUS se salda a mano
569    # con `campana-deuda.sh`, que es manual **a propósito** (el store del worker es parcial: si
570    # midiera la deuda él mismo se pondría a reconstruir media distro — ver su cabecera).
571    #
572    # O sea que el eslabón que faltaba no era una herramienta: era que el latido, que ya tiene la
573    # medición, dijera la lista y el comando. Sale sólo cuando hay algo que hacer.
574    python3 - docs/state/cierres.json <<'PY' || true
575import json, sys
576try:
577    d = json.load(open(sys.argv[1]))
578except Exception:
579    sys.exit(0)
580culpables, perfiles, recortados = {}, 0, 0
581for p in d.get('perfiles', []):
582    imp = p.get('impedidos') or []
583    if not imp:
584        continue
585    perfiles += 1
586    # `cierre-perfiles.py` guarda a lo sumo 10 impedidos por perfil y el total aparte. Si recortó,
587    # esta lista cuenta de menos y hay que decirlo: un recorte callado se lee como «lo cubrí todo».
588    if (p.get('n_impedidos') or len(imp)) > len(imp):
589        recortados += 1
590    for linea in imp:
591        ruta = linea.split(' ')[0]
592        cola, _, nombre = ruta.partition('/')
593        culpables.setdefault((cola, nombre), set()).add(p['perfil'])
594if not culpables:
595    sys.exit(0)
596# Orden por cuántos perfiles destraba cada una: la primera es la que más paga.
597orden = sorted(culpables, key=lambda k: (-len(culpables[k]), k[1]))
598print('   ⇒ %d receta(s) en deuda bloquean la clausura de %d perfil(es):' % (len(orden), perfiles))
599for cola, nombre in orden[:10]:
600    print('        %-22s destraba %d perfil(es): %s'
601          % (nombre, len(culpables[(cola, nombre)]), ', '.join(sorted(culpables[(cola, nombre)]))))
602delcorpus = [n for c, n in orden if c == 'corpus']
603if recortados:
604    print('     ⚠ %d perfil(es) traen MÁS impedidos de los que el informe guarda (tope de 10):'
605          ' la lista de arriba cuenta de menos.' % recortados)
606if delcorpus:
607    print('     Nadie las construye solo (el bucle del worker muele incoming/, no el corpus):')
608    print('        ssh root@<worker> "systemd-run --unit=campana-deuda --working-directory=/opt/takana \\')
609    print('          /opt/takana/scripts/farm/campana-deuda.sh %s"' % ' '.join(delcorpus[:10]))
610PY
611  else
612    echo "   ⚠ cierre-perfiles falló — NO piso el informe anterior"
613    tail -2 work/.cierres.salida 2>/dev/null | sed 's/^/      /'
614  fi
615  rm -f work/.cierres.tmp work/.cierres.salida
616
617  # ── ¿ALGUNA IMAGEN QUEDÓ RANCIA? (2026-10-10, SDD 33 §4) ─────────────────────────────────────
618  # Cierra el bucle del §4: `cierre-perfiles.py` dice cuál es el cierre de hoy, `hydrate-profile.py`
619  # hace que la imagen declare el suyo, y esto COMPARA. Sin este paso las otras dos piezas son dos
620  # ficheros que no se encuentran.
621  #
622  # ⚠ PUERTA DIARIA, y no por coste —son milisegundos— sino para no imprimir «0 imágenes» 48 veces
623  # al día. Hoy el registro está vacío a propósito: no hay imágenes publicadas. Una línea diaria
624  # mantiene la capacidad VIVA y visible; 48 la vuelven invisible, que es el modo de fallo que este
625  # guion ya sufrió con el aviso del repo (una línea por hora durante 18 días en un log sin lector).
626  IMG_SELLO="work/.imagen-rancia.sello"
627  if [ ! -f "$IMG_SELLO" ] || [ -n "$(find "$IMG_SELLO" -mtime +0 2>/dev/null)" ]; then
628    touch "$IMG_SELLO"
629    if python3 scripts/imagen-rancia.py --registro docs/state/imagenes.txt \
630         > work/.imagen-rancia.salida 2>&1; then
631      echo "   imagen-rancia ✓ $(sed -n '1s/^== //;1s/ ==$//;1p' work/.imagen-rancia.salida)"
632      sed -n '/RANCIA\|a rearmar\|sin declarar/p' work/.imagen-rancia.salida | sed 's/^/      /' | head -6
633    else
634      echo "   ⚠ imagen-rancia falló"
635      tail -2 work/.imagen-rancia.salida | sed 's/^/      /'
636    fi
637    rm -f work/.imagen-rancia.salida
638  fi
639
640  RIO_SELLO="work/.upstream-vigia.sello"
641  if [ ! -f "$RIO_SELLO" ] || [ -n "$(find "$RIO_SELLO" -mtime +0 2>/dev/null)" ]; then
642    echo "   río arriba: puerta diaria abierta, preguntando a 660 fuentes git (~20 s)…"
643    touch "$RIO_SELLO"   # sello = LIMITADOR DE RITMO, no marca de éxito: se toca salga como salga,
644                         # o un fallo de red lo reintentaría cada 30 min contra servidores ajenos.
645    # A un temporal, y `mv` sólo si tiene contenido: un informe VACÍO publicado borraría el
646    # anterior y el khipu diría «sin novedad» por un hueco (regla 3 de CLAUDE.md).
647    if nice -n 19 python3 scripts/fuentes/upstream-vigia.py --json work/.upstream.tmp \
648         > work/.upstream-vigia.salida 2>&1 && [ -s work/.upstream.tmp ]; then
649      mv work/.upstream.tmp docs/state/upstream.json
650      echo "   upstream.json ✓ $(python3 -c "
651import json; d=json.load(open('docs/state/upstream.json'))
652print(f\"{d['con_tag_nuevo']} git con tag nuevo · {d.get('tarballs_con_version_nueva','?')} tarballs con version nueva · {d.get('tarballs_indicio_con_fecha','?')}+{d.get('tarballs_indicio_sin_fecha','?')} indicios (con/sin fecha) · {d['tarballs_sin_mirar']} sin mirar\")" 2>/dev/null || echo '(sin resumen)')"
653    else
654      echo "   ⚠ upstream-vigia falló o salió vacío — NO piso el informe anterior"
655      sed -n '$p' work/.upstream-vigia.salida 2>/dev/null | sed 's/^/      /'
656    fi
657    rm -f work/.upstream.tmp work/.upstream-vigia.salida
658  else
659    echo "   río arriba: sello de hoy, no toca (puerta diaria)"
660  fi
661
662  AUDIT_SELLO="work/.static-audit.sello"
663  if [ ! -f "$AUDIT_SELLO" ] || [ -n "$(find "$AUDIT_SELLO" -mtime +0 2>/dev/null)" ]; then
664  # ── VIGÍA DE SONAMES (2026-09-06) ────────────────────────────────────────────────────────────
665  # `vigia-sonames.py` existía desde antes y contesta la pregunta que el grafo NO contesta: no
666  # «¿está sellado?» sino «¿arranca?». Encuentra los `NEEDED` que ningún artefacto del cierre
667  # publica y que el rootfs hidratado resolvía contra el sysroot del LAB.
668  #
669  # NADIE LO CORRÍA. Por eso `libstdc++.so.6` —que rompía el navegador en los CUATRO perfiles—
670  # estuvo en su salida sin que nadie lo leyera, y se redescubrió el 2026-09-06 arrancando `atuq`
671  # a mano. Un vigía que hay que acordarse de invocar no se distingue de no tenerlo: es el mismo
672  # argumento que `scripts/test-atuq-politica.py` hace sobre los guardianes.
673  # Deja fichero de estado para que la salida quede en el repo y se vea si empeora entre cosechas.
674  python3 scripts/vigia-sonames.py > docs/state/sonames.txt 2>&1 \
675    && echo "   sonames.txt ✓ $(grep -c 'FALTA' docs/state/sonames.txt) hueco(s) sin proveedor" \
676    || echo "   ⚠ vigia-sonames.py falló"
677
678  # ── VIGÍA DE SERVICIOS (2026-09-12, SDD 30 §4a) ──────────────────────────────────────────────
679  # Contesta la pregunta que ni el grafo ni `vigia-sonames` contestan: no «¿está sellado?» ni
680  # «¿arranca el binario?» sino **«¿hay alguien que lo LEVANTE?»**. Un demonio puede estar sellado,
681  # con contenido, con todos sus sonames resueltos… y que ninguna imagen lo arranque nunca.
682  #
683  # Va al latido por la misma lección que dejó escrita `vigia-sonames` doce líneas más arriba —un
684  # vigía que hay que acordarse de invocar no se distingue de no tenerlo— y con más motivo: su
685  # entrada son DOS ficheros que cambian por separado (las recetas y `targets.toml`), así que la
686  # divergencia entre «declarado» y «habilitado» aparece sin que nadie toque el vigía.
687  #
688  # Corre PRIMERO su propio `--selftest`: si el guardián está roto, su silencio no es una respuesta.
689  { echo "servicios por imagen · medido $(ts)"
690    echo "── autoprueba del vigía (el primer caso es el CONTROL, tiene que pasar)"
691    python3 scripts/targets.py --selftest 2>&1 | sed 's/^/   /'
692    for perf in $(python3 scripts/targets.py --lista 2>/dev/null | awk '{print $1}'); do
693      echo "── $perf"
694      python3 scripts/targets.py --services "$perf" 2>&1 | sed 's/^/   /'
695    done
696  } > work/.servicios.tmp 2>&1 && mv work/.servicios.tmp docs/state/servicios.txt \
697    && echo "   servicios.txt ✓ $(grep -c '^   ERROR' docs/state/servicios.txt) error(es), $(grep -c '^   AVISO' docs/state/servicios.txt) aviso(s)" \
698    || echo "   ⚠ vigía de servicios falló"
699
700    echo "   static-audit: puerta diaria abierta, barriendo el store (~8 min, nice 19)…"
701    nice -n 19 scripts/static-audit.sh > work/.static-audit.salida 2>&1
702    audit_rc=$?
703    touch "$AUDIT_SELLO"
704    if [ -s work/.static-audit.salida ]; then
705      # La primera línea lleva la FECHA DE MEDICIÓN, y no es decoración. Con el frente en verde el
706      # texto del audit es constante ("MIENTEN: 0") ⇒ `git diff --cached --quiet` no vería cambio,
707      # no se commitearía nada, y dentro de tres meses este fichero sería indistinguible de uno
708      # rancio de hoy. Con la fecha, cada barrido deja huella en el `git log`: el khipu muestra que
709      # el vigía sigue vivo, no sólo que el último veredicto fue bueno. Es la misma trampa que el
710      # build-state-wlr congelado 17 días — un fichero que nadie refresca envejece hacia el
711      # optimismo, y acá la prueba de frescura es lo único que distingue "verde" de "sin mirar".
712      { echo "audit de enlace estático · medido $(date -u +%FT%TZ) · rc=$audit_rc"
713        cat work/.static-audit.salida
714      } > work/.static-audit.tmp
715      mv work/.static-audit.tmp docs/state/static-audit.txt
716      echo "   static-audit.txt ✓ (rc=$audit_rc) $(grep -m1 '^══' docs/state/static-audit.txt || echo '(sin resumen)')"
717    else
718      echo "   ⚠ static-audit no produjo salida (rc=$audit_rc) — NO piso el veredicto anterior"
719    fi
720    rm -f work/.static-audit.salida work/.static-audit.tmp
721  else
722    echo "   static-audit: sello de hoy, no toca (puerta diaria)"
723  fi
724else
725  echo "   ⚠ sin binario hammer en $HAMMER — no regenero estado"
726fi
727
728# 4. Commitear SÓLO el estado firme (el avance que el humano sigue). Recetas nuevas que YO autoré se
729# commitean aparte, a mano, para no meter autoría a medias en un commit de cron.
730if [ "${NO_COMMIT:-}" != "1" ]; then
731  # ── PUBLICAR SIN DEPENDER DEL ÁRBOL NI DE LA RAMA LOCAL (2026-10-07) ─────────────────────────
732  # Acá había `pull --ff-only` + `git add` + `git commit -- <rutas>` + `push`, con
733  # `git-sincronizar.sh` de plan B. Eso ataba la publicación a dos estados que el latido NO
734  # controla —el árbol de trabajo y la rama local— y los dos se ensucian solos en una caja
735  # compartida. El plan B tampoco servía: `git-sincronizar.sh` hace `pull --rebase`, que se niega
736  # con el árbol sucio.
737  #
738  # MEDIDO el 2026-10-07: `/opt/takana` llevaba **580 commits locales y 12 días sin publicar**.
739  # Causa doble — un `scripts/servidor/claude-caja.sh` modificado (que resultó ser BYTE A BYTE lo
740  # que ya estaba en origin, o sea trabajo duplicado de otro clon) y la rama divergida, que deja
741  # el `--ff-only` sin vuelta posible para siempre. Y el `--ff-only` es a propósito: regla 2 ter,
742  # donde un `pull --rebase` en este árbol se llevó un commit por delante.
743  #
744  # El latido latía cada 30 min diciendo «NO pude publicar el estado — queda local», y nadie lo
745  # leía: es la forma exacta de fallo que este guion ya documentó una vez («un latido que late y
746  # no publica, para siempre») y que volvió por otra puerta. Los 580 commits contenían, en total,
747  # CUATRO LÍNEAS de timestamp.
748  #
749  # `publicar-estado.sh` arma el commit con plumbing sobre el `origin/main` recién traído, en un
750  # índice TEMPORAL: no toca el árbol, no toca el índice compartido (regla 2), no le hace falta que
751  # la rama local esté al día, y si otro empuja en el medio reconstruye sobre la cabeza nueva.
752  # Nunca fuerza — el rechazo ES la señal, y forzar pisaría al otro.
753  "$ROOT/scripts/farm/publicar-estado.sh" \
754      --message "estado: cosecha granja $(ts) — avance del árbol KDE" \
755    || echo "==> ⚠ no pude publicar el estado — el informe de arriba dice por qué"
756fi
757
758echo "── $(ts) cosecha-cron fin"