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…
khipu: el grafo de sway llevaba 17 días congelado — el latido nunca corría --wlr
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, y commitea SÓLO esos ficheros firmes. El `git log` de docs/state/ ES el avance
10# que el humano ve y sigue (huecos, fallos, qué se selló entre dos ciclos).
11#
12# LO QUE **NO** HACE (a propósito): NO promueve incoming-kde→recipes/ canónico (esa es decisión de
13# clasificación humana — "el hub clasifica", ver granja-promote-colisiones) ni firma el índice. Sólo
14# captura al store del laptop y deja el veredicto para la mañana. Idempotente y re-corrible.
15#
16# INFINITO: vive en el crontab hasta que lo saques (`crontab -e`, borrar la línea cosecha-cron).
17# Robusto a worker-ausente: si el dead-man ya mató al worker (cola seca ⇒ €0), el paso 1/2 falla
18# suave, se loguea y igual regenera+commitea el estado local. NO relanza workers (eso gasta €;
19# es decisión explícita con farm-up).
20#
21# Uso: scripts/farm/cosecha-cron.sh # un ciclo (lo que dispara el cron)
22# */30 * * * * cd /home/sergio/hammer && scripts/farm/cosecha-cron.sh >>work/cosecha-cron.log 2>&1
23# Env: SSH_KEY (def ~/.ssh/github5), REMOTE (def /opt/hammer), NO_COMMIT=1 (regenera pero no commitea)
24set -uo pipefail
25
26ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
27cd "$ROOT"
28
29# LOCK (2026-07-22): el latido ya no viene sólo del crontab — `latido.sh` lo lanza desde la sesión
30# porque el laptop arranca a veces con arje-zero y a veces con OpenRC, y cronie sólo existe en uno
31# de los dos. Con dos disparadores posibles, dos ciclos pueden solaparse: ambos hacen rsync sobre el
32# mismo store y, peor, `git commit`+`push` a la vez (index.lock, o un push que pisa al otro). El lock
33# va ACÁ y no en el llamador para que valga venga de donde venga: cron, latido o a mano.
34# -n = no esperar: si ya hay un ciclo corriendo, este sobra (el próximo tick recoge lo mismo).
35LOCKFILE="${LOCKFILE:-$ROOT/work/.cosecha-cron.lock}"
36mkdir -p "$(dirname "$LOCKFILE")"
37exec 9>"$LOCKFILE"
38if ! flock -n 9; then
39 echo "── $(date -u +%FT%TZ) cosecha-cron: ya hay un ciclo en curso ⇒ salgo (no me solapo)"
40 exit 0
41fi
42
43SSH_KEY="${SSH_KEY:-$HOME/.ssh/github5}"
44REMOTE="${REMOTE:-/opt/hammer}"
45FLEET="$ROOT/scripts/farm/.fleet"
46HAMMER="${HAMMER:-$ROOT/target/release/hammer}"
47# Workers efímeros reciclan IPs ⇒ no fijar known_hosts (hub-and-spoke, worker sin secretos).
48SSH="ssh -i $SSH_KEY -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=15"
49
50ts() { date -u +%FT%TZ; }
51echo "── $(ts) cosecha-cron arranca"
52
53if [ ! -s "$FLEET" ]; then
54 echo " flota vacía (scripts/farm/.fleet) — nada que cosechar este ciclo"
55else
56 while read -r name ip; do
57 [ -n "${name:-}" ] || continue
58 echo "==> $name ($ip)"
59 # 1. SIEMBRA: código + recetas laptop→worker (--delete: el laptop manda; nunca toca work/store/target).
60 if rsync -az --delete -e "$SSH" \
61 --exclude /work --exclude /store --exclude '/store-*' --exclude /target \
62 --exclude /dist --exclude /.dev-fs --exclude /.git --exclude /.scratch \
63 --exclude '*.png' --exclude '/content*' \
64 ./ "root@$ip:$REMOTE/" 2>&1 | tail -2; then
65 echo " siembra ✓"
66 else
67 echo " ⚠ siembra falló (worker ausente/idle-muerto?) — sigo con el siguiente"
68 continue
69 fi
70 # 2. RECOGE: store sellado worker→laptop (CAS, merge seguro).
71 # ── ⚠ EXCLUIR LO YA PODADO, O LA POda SE DESHACE SOLA ─────────────────────────────────────
72 # El worker NO se poda nunca, así que conserva los artefactos superados y este rsync los
73 # devolvía enteros. Con el latido cada 30 min, una poda de 24 G quedaba deshecha en menos de
74 # una hora: medido el 2026-08-07, tres podas seguidas borraron LOS MISMOS 362 artefactos.
75 #
76 # El mismo arreglo está en `farm-sync.sh` — y ahí estuvo el error de método: lo puse allí, di el
77 # bucle por cerrado, y la churn siguió porque **hay DOS rutas que bajan el store** y el latido
78 # usa ésta. Arreglar una de dos copias y declarar victoria es peor que no arreglar: el síntoma
79 # se atenúa lo justo para dejar de mirar. Si aparece una tercera copia, va con el mismo exclude.
80 #
81 # ── 2026-08-09: EL STORE YA NO BAJA. BAJA EL MANIFIESTO ───────────────────────────────────
82 # El store se mudó al VOLUMEN de la granja y el laptop se quedó con lo que la granja no puede
83 # rehacer. Seguir bajando el store acá desharía esa mudanza cada 30 minutos — la misma forma
84 # exacta del bucle de churn que este bloque documenta arriba, y la razón por la que el exclude
85 # de abajo dejó de alcanzar: ya no se trata de filtrar QUÉ baja, sino de que no baja.
86 #
87 # Lo que el hub sí necesita es SABER qué hay sellado, para que el grafo de estado no reporte
88 # `never` sobre artefactos que existen. Eso son kilobytes: la lista de nombres. Se acumula en
89 # el manifiesto (nunca se pisa) porque el volumen es una sola foto y el hub recuerda todas.
90 if $SSH "root@$ip" "ls $REMOTE/store 2>/dev/null | grep -E '^[0-9a-f]{64}-'" \
91 > "$ROOT/work/.sellados-worker" 2>/dev/null && [ -s "$ROOT/work/.sellados-worker" ]; then
92 N_W=$(wc -l < "$ROOT/work/.sellados-worker")
93 cat "$ROOT/work/farm-sellados.txt" "$ROOT/work/.sellados-worker" 2>/dev/null \
94 | grep -E '^[0-9a-f]{64}-' | sort -u > "$ROOT/work/farm-sellados.txt.new"
95 mv "$ROOT/work/farm-sellados.txt.new" "$ROOT/work/farm-sellados.txt"
96 rm -f "$ROOT/work/.sellados-worker"
97 echo " manifiesto ✓ (worker: $N_W · total: $(wc -l < "$ROOT/work/farm-sellados.txt"))"
98 else
99 echo " ⚠ no pude leer el manifiesto del worker — sigo"
100 fi
101 # ⚠ Lo que NO cubre esto: los artefactos nuevos quedan SÓLO en el volumen hasta que alguien
102 # haga una pasada de respaldo. El volumen tiene borrado protegido, pero es UNA copia.
103 done < "$FLEET"
104fi
105
106# 2.5 REAPER HUB-SIDE (2026-07-23). El dead-man DEL WORKER probó FRÁGIL: un worker quedó 3.5h idle
107# quemando € porque su `/etc/hammer-deadman.env` no tenía token válido ⇒ el dead-man llegaba a
108# matarse pero salía 1 "sin HCLOUD_TOKEN" cada tick. La garantía "un vps idle es inadmisible" NO
109# puede depender de que cada worker se auto-provisione bien un token (falla en silencio). El HUB sí
110# tiene un token que funciona, y este latido corre cada 30 min aunque no haya sesión (setsid) ⇒ el
111# hub es la AUTORIDAD de vida del worker. Es defensa en profundidad: el dead-man del worker cubre
112# "hub caído"; esto cubre "dead-man del worker roto". Un worker sin trabajo activo REAPER_MAX ciclos
113# seguidos se BORRA desde acá, con el MISMO blindaje que el dead-man (label role=hammer-worker;
114# gioser no tiene label ⇒ jamás pasa, ni por accidente).
115REAPER_MAX="${REAPER_MAX:-2}" # 2 ciclos × 30 min ≈ 1 h idle, igual que el dead-man del worker
116if [ -s "$FLEET" ] && command -v hcloud >/dev/null 2>&1; then
117 mkdir -p "$ROOT/work/.reaper"; sobreviven=""
118 while read -r name ip; do
119 [ -n "${name:-}" ] || continue
120 # LISTA NEGRA (capa 1 de 2, igual que el dead-man): gioser JAMÁS se toca, pase lo que pase.
121 # Segunda capa = el chequeo de label abajo. gioser no tiene label Y no matchea worker de la
122 # granja ⇒ imposible que muera por acá. "Ahí está nuestra vida" (2026-07-23).
123 case "$name" in *gioser*) echo "==> reaper: $name en LISTA NEGRA ⇒ intocable"; sobreviven="${sobreviven}${name} ${ip}
124"; continue;; esac
125 strike_f="$ROOT/work/.reaper/$name"
126 # ¿el server siquiera existe? Si ya no está en hcloud (borrado a mano o por un ciclo previo),
127 # NO cuesta € y no debe quedar en .fleet para siempre. Se dropea (no entra a `sobreviven`).
128 if ! hcloud server describe "$name" -o format='{{.Name}}' >/dev/null 2>&1; then
129 echo "==> reaper: $name ya no existe en hcloud ⇒ lo saco de .fleet"; rm -f "$strike_f"; continue
130 fi
131 if $SSH root@"$ip" 'pgrep -f "hammer build|campana-deuda|farm-worker-loop" >/dev/null 2>&1'; then
132 echo 0 > "$strike_f"; sobreviven="${sobreviven}${name} ${ip}
133"; continue
134 fi
135 strikes=$(( $(cat "$strike_f" 2>/dev/null || echo 0) + 1 )); echo "$strikes" > "$strike_f"
136 echo "==> reaper: $name SIN trabajo activo — strike $strikes/$REAPER_MAX"
137 if [ "$strikes" -lt "$REAPER_MAX" ]; then sobreviven="${sobreviven}${name} ${ip}
138"; continue; fi
139 # BLINDAJE (igual que el dead-man): sólo se borra un server con label role=hammer-worker.
140 if hcloud server describe "$name" -o format='{{.Labels}}' 2>/dev/null | grep -q hammer-worker; then
141 echo " ☠ IDLE $strikes ciclos ⇒ el HUB borra $name (label verificado; gioser protegido)"
142 if hcloud server delete "$name" >/dev/null 2>&1; then rm -f "$strike_f"
143 else echo " ⚠ delete falló — sobrevive, reintenta próximo ciclo"; sobreviven="${sobreviven}${name} ${ip}
144"; fi
145 else
146 echo " ⛔ $name SIN label hammer-worker ⇒ NO se borra (protegido)"; sobreviven="${sobreviven}${name} ${ip}
147"
148 fi
149 done < "$FLEET"
150 printf '%s' "$sobreviven" | grep -v '^[[:space:]]*$' > "$FLEET.tmp" 2>/dev/null; mv "$FLEET.tmp" "$FLEET" 2>/dev/null || true
151fi
152
153# 3. Regenerar el grafo de estado desde el store ya cosechado (usa el hammer del laptop, que tiene
154# el subcomando `hash`). Barato (~14s cada uno). El corpus canónico y el frente KDE, por separado.
155echo "==> regenerando grafo de estado"
156if [ -x "$HAMMER" ]; then
157 scripts/build-state.py >/dev/null 2>&1 && echo " build-state.json ✓" || echo " ⚠ build-state.py falló"
158 scripts/build-state.py --kde >/dev/null 2>&1 && echo " build-state-kde.json ✓" || echo " ⚠ build-state.py --kde falló"
159 # GNOME faltaba acá (2026-07-27): el latido regeneraba base+KDE y NUNCA el grafo GNOME, así que
160 # build-state-gnome.json envejecía en silencio y reportaba `never` sobre recetas selladas hacía
161 # días (gobject-introspection, toda la onda 2). Un grafo viejo miente con la misma cara que uno
162 # fresco — por eso lo regenera el latido y no la mano.
163 scripts/build-state.py --gnome >/dev/null 2>&1 && echo " build-state-gnome.json ✓" || echo " ⚠ build-state.py --gnome falló"
164 # COSMIC (2026-08-03), por la MISMA razón por la que se agregó GNOME arriba: un frente que no
165 # regenera el latido envejece en silencio, y un grafo viejo miente con la misma cara que uno fresco.
166 scripts/build-state.py --cosmic >/dev/null 2>&1 && echo " build-state-cosmic.json ✓" || echo " ⚠ build-state.py --cosmic falló"
167 # wlr/sway (2026-08-26), TERCERA vez que cae el mismo cable. El flag `--wlr` se escribió el día que
168 # se abrió el frente, y su propio comentario en build-state.py dice «sin esto el frente es INVISIBLE
169 # para el khipu» — pero la línea nunca se agregó ACÁ. Resultado medido: build-state-wlr.json se
170 # quedó congelado el 2026-08-09 diciendo `escritorio-sway 121/121`, y 17 días después la realidad
171 # era 108/122: freetype, make, python3 y libpng-pic se re-hashearon en el medio y arrastraron a 14
172 # dependientes a deuda. Nadie lo vio porque el número que todo el mundo mira estaba perfecto.
173 # Un grafo viejo miente con la misma cara que uno fresco — y el que nadie regenera miente MÁS,
174 # porque envejece en la dirección optimista: sólo puede sobreestimar lo que hay sellado.
175 scripts/build-state.py --wlr >/dev/null 2>&1 && echo " build-state-wlr.json ✓" || echo " ⚠ build-state.py --wlr falló"
176 # Cola DERIVADA del grafo (P4): el orden de masticado por imagen, en ondas topológicas. No lanza
177 # ningún build — sólo deja escrito qué conviene moler primero, para que la cola deje de ser una
178 # lista escrita a mano. Barato (lee JSON, no hashea).
179 scripts/drenar.py --todos >/dev/null 2>&1 && echo " drenaje.json ✓" || echo " ⚠ drenar.py falló"
180else
181 echo " ⚠ sin binario hammer en $HAMMER — no regenero estado"
182fi
183
184# 4. Commitear SÓLO el estado firme (el avance que el humano sigue). Recetas nuevas que YO autoré se
185# commitean aparte, a mano, para no meter autoría a medias en un commit de cron.
186if [ "${NO_COMMIT:-}" != "1" ]; then
187 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 2>/dev/null || true
188 if ! git diff --cached --quiet 2>/dev/null; then
189 git commit -q -m "estado: cosecha granja $(ts) — avance del árbol KDE" 2>/dev/null \
190 && { git push -q origin main 2>/dev/null && echo "==> estado commiteado+pusheado" \
191 || echo "==> estado commiteado (push falló, reintenta próximo ciclo)"; }
192 else
193 echo "==> sin cambios de estado este ciclo"
194 fi
195fi
196echo "── $(ts) cosecha-cron fin"