#!/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)" 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")" exec 9>"$LOCKFILE" if ! flock -n 9; then echo "── $(date -u +%FT%TZ) cosecha-cron: ya hay un ciclo en curso ⇒ salgo (no me solapo)" exit 0 fi SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}" REMOTE="${REMOTE:-/opt/hammer}" FLEET="$ROOT/scripts/farm/.fleet" HAMMER="${HAMMER:-$ROOT/target/release/hammer}" # 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). if rsync -az --delete -e "$SSH" \ --exclude /work --exclude /store --exclude '/store-*' --exclude /target \ --exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \ --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=hammer-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 echo "==> reaper: $name ya no existe en hcloud ⇒ lo saco de .fleet"; rm -f "$strike_f"; 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=hammer-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 `hammer-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 hammer 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ó" # 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). scripts/drenar.py --todos >/dev/null 2>&1 && echo " drenaje.json ✓" || echo " ⚠ drenar.py falló" # VIGÍA DE FUENTES (ADR 0013 §3). Desde que el mirror propio se consulta ANTES que upstream, # `hammer 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 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/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 2>/dev/null || true if ! git diff --cached --quiet 2>/dev/null; then git commit -q -m "estado: cosecha granja $(ts) — avance del árbol KDE" 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"