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…
vigía: mirar también la caché de módulos de Go
1#!/usr/bin/env bash
2# reconstruir-corpus.sh — reconstruye TODO lo que esté en deuda, en serie y de forma reanudable.
3#
4# ── CUÁNDO SE USA ───────────────────────────────────────────────────────────────────────────────
5# Cuando algo mueve el `ArtifactHash` de todo el corpus a la vez. Hoy eso es UNA cosa: cambiar la
6# imagen del lab (`scripts/lab-image.sh --crear` + pinear el sha nuevo), porque desde el 2026-08-10
7# el toolchain entra en `hash_inputs`. No es un accidente que sea caro: ése es el precio explícito
8# de cambiar de compilador, y antes se pagaba sin enterarse (dos labs sellaban bytes distintos en
9# la misma dirección).
10#
11# ── POR QUÉ EN SERIE, Y NO ES NEGOCIABLE ────────────────────────────────────────────────────────
12# `hammer build` comparte `work/sources/<dep>-<sha>`. Dos builds que compartan una dep se pisan el
13# árbol y queda ROTO PARA SIEMPRE (ADR 0012, sin decidir; ~93 de 205 recetas KDE murieron así al
14# invalidar libdrm). Todo el run va bajo UN `flock work/.farm-build.lock`, el mismo que toman los
15# scripts de la granja, así que también nos serializa con ellos y con cualquier otro agente.
16#
17# ── REANUDABLE POR CONSTRUCCIÓN ─────────────────────────────────────────────────────────────────
18# No lleva estado propio: la pregunta "¿esto ya está?" se la hace al store con `hammer hash --check`
19# (~2 ms, sin construir). Matarlo y relanzarlo continúa donde iba. Un run entero sobre un corpus ya
20# construido son unos segundos de puros --check.
21#
22# ── EL DISCO ES EL LÍMITE REAL, NO LA CPU ───────────────────────────────────────────────────────
23# Cada sellado añade un artefacto y cada build deja su árbol en `work/sources/`. Entre receta y
24# receta —con NADA corriendo, que es cuando es trivialmente seguro— se purgan los árboles si el
25# disco baja del umbral. Purgar CON un build en vuelo le arranca los ficheros al compilador: pasó
26# el 2026-08-10 y se llevó por delante un kernel a medias.
27#
28# Uso: scripts/reconstruir-corpus.sh [--seco]
29# Env: MIN_LIBRE_GB (def 40) umbral de purga · ABORT_GB (def 15) aborta antes de llenar el disco
30# CARGO_HOME (def ~/.cargo) — se VIGILA su sistema de ficheros además del del repo
31set -uo pipefail
32
33ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
34HAMMER="${HAMMER:-$ROOT/target/release/hammer}"
35STORE="${STORE:-./store}"
36LOCK="$ROOT/work/.farm-build.lock"
37LOG="${LOG:-$ROOT/work/reconstruccion.log}"
38# 25/10 SE QUEDÓ CORTO: el 2026-08-13 la tanda abortó con 7 G tras purgar a los 25 — o sea que UNA
39# sola receta se comió >18 G entre purga y purga. Son los `cargo vendor` de las recetas Rust grandes,
40# que sueltan varios GB cada una. Con 40/15 hay margen para que la purga ocurra ANTES de que una
41# receta gorda agote lo que queda, en vez de justo después.
42MIN_LIBRE_GB="${MIN_LIBRE_GB:-40}"
43ABORT_GB="${ABORT_GB:-15}"
44SECO=0; [ "${1:-}" = "--seco" ] && SECO=1
45
46# ── EL VIGÍA MIRA DOS SISTEMAS DE FICHEROS, NO UNO ──────────────────────────────────────────────
47# Medía sólo el FS del repo, y las recetas Rust no gastan sólo ahí: `cargo vendor` copia DESDE
48# `~/.cargo/registry`, que en gioser está en `/` (74 G) y no en `/mnt/vvv` (255 G). El 2026-08-20 eso
49# era 138 G libres en el repo y 19 G en `/`, o sea que el vigía habría dado luz verde mientras el que
50# se llenaba de verdad era el otro — y llenar `/` no rompe una tanda, rompe la máquina. Se toma el
51# MÍNIMO de los dos. Si comparten FS, `df` devuelve lo mismo dos veces y esto es inocuo.
52#
53# ── 2026-08-27: MIRA DÓNDE SE GASTA, NO DÓNDE ESTÁ EL REPO ──────────────────────────────────────
54# Medir `$ROOT` dejó de ser correcto al mudar el store al volumen `harkaq-cosecha`. `$ROOT` es
55# /mnt/vvv, que hammer COMPARTE con tawasuyu, y tawasuyu repuebla su target a ~62 G/h: el 27/08 se
56# comió los 92 G que se le habían liberado en 70 minutos y el vigía abortó la tanda por un disco
57# que hammer ya ni usaba. Un vigía que mide el disco de OTRO no protege la tanda, la mata.
58# Ahora se miden los tres sitios donde hammer SÍ crece —store, árboles de fuentes y CARGO_HOME—
59# y se toma el mínimo. Si comparten FS, `df` repite el número y esto es inocuo.
60CARGO_H="${CARGO_HOME:-$HOME/.cargo}"
61# ── GOPATH TAMBIÉN CRECE, y llenó `/` hasta 0 BYTES el 2026-08-28 ───────────────────────────────
62# El vigía miraba store/sources/CARGO_HOME pero NO la caché de módulos de Go. Esa noche pasó de
63# 498 MB a **25 G** construyendo las recetas Go y dejó `/dev/sda2` al 100% — sin espacio ni para el
64# resto de la máquina (gitea, caddy). Los builds Go morían con
65# `write /home/sergio/go/pkg/mod/cache/download/…: no space left on device`, que NO se parece a un
66# fallo de disco del build porque la ruta no es del repo. Se movió a `/mnt/cosecha/gopath` (symlink,
67# igual que ~/.cargo) y se añade acá para que el vigía lo vea aunque alguien lo devuelva a `/`.
68GO_MODCACHE="${GOMODCACHE:-$(go env GOMODCACHE 2>/dev/null || echo "$HOME/go/pkg/mod")}"
69avail_gb() { df -BG --output=avail "$1" 2>/dev/null | tail -1 | tr -dc 0-9; }
70libre_gb() {
71 local m=999999 v d
72 for d in "$STORE" "$ROOT/work/sources" "$CARGO_H" "$GO_MODCACHE"; do
73 [ -e "$d" ] || continue
74 v=$(avail_gb "$d"); [ -z "${v:-}" ] && continue
75 [ "$v" -lt "$m" ] && m=$v
76 done
77 echo "$m"
78}
79ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
80
81# EL ORDEN IMPORTA, aunque las deps se resuelvan solas. `hammer build` construye recursivamente lo
82# que le falta, así que cualquier orden termina — pero no cualquier orden es útil a mitad de camino.
83# Hay 1186 ficheros de receta y sólo ~440 alcanzan alguna imagen; el resto son catálogo y colas de
84# staging. Construyendo por orden alfabético, tras un día de máquina no habría NI UNA imagen
85# completa. Se ordena por objetivo: primero las clausuras de los perfiles de `targets.toml`
86# (base, cli, escritorio-*), después todo lo demás. Así cada perfil que cierra es utilizable ya.
87# ORDEN se puede IMPONER desde fuera (2026-08-23): `ORDEN=work/faltan-perfiles.txt` corre una lista
88# concreta en vez del corpus entero, y hereda gratis el flock, el vigía de disco y el log de acá.
89# Nació de tener 17 recetas sueltas —las que cierran las seis imágenes— y ninguna forma de pasarlas
90# sin reconstruir 780. Si el fichero ya existe y no está vacío, NO se regenera: la lista del que
91# llama manda. Admite tanto rutas `recipes/…/x.toml` como nombres a secas.
92ORDEN="${ORDEN:-$ROOT/work/reconstruccion-orden.txt}"
93if [ -s "$ORDEN" ] && [ -n "${ORDEN_IMPUESTO:-}" ]; then
94 echo " orden IMPUESTO desde $ORDEN ($(grep -c . "$ORDEN") entradas) — no regenero"
95else
96python3 - "$ORDEN" <<'PY'
97import json, os, sys, subprocess
98# Las raíces salen de `targets.toml` vía su expansor oficial (scripts/targets.py), NO de los nodos
99# de build-state: ese grafo es el corpus ENTERO, así que usarlo marcaba 1161 de 1161 como
100# prioritarias y el orden no ordenaba nada. `targets.py` da lo que una imagen pide por nombre.
101PERFILES = ["base", "cli", "escritorio-mirada", "escritorio-sway",
102 "escritorio-cosmic", "escritorio-kde", "escritorio-gnome"]
103prioritarios, orden_perfil = set(), []
104for p in PERFILES:
105 r = subprocess.run(["python3", "scripts/targets.py", p], capture_output=True, text=True)
106 if r.returncode != 0:
107 continue
108 nombres = [l.strip() for l in r.stdout.split() if l.strip()]
109 orden_perfil.append((p, len(nombres)))
110 prioritarios.update(nombres)
111# La CLAUSURA (las libs de las que cuelgan las raíces) se saca del grafo siguiendo las aristas.
112d = json.load(open("docs/state/build-state.json"))
113nodos = d.get("nodes", {})
114pila, vistos = list(prioritarios), set()
115while pila:
116 x = pila.pop()
117 if x in vistos:
118 continue
119 vistos.add(x)
120 pila += nodos.get(x, {}).get("deps", [])
121prioritarios = vistos
122# os.walk y no glob: `recipes/**/*.toml` se dejaba 25 ficheros fuera y no se nota mirando el total.
123#
124# `.deferred/` FUERA: son recetas APARCADAS por diseño y no construyen sueltas — sus `deps.build`
125# resuelven contra el directorio hermano, que ahí no existe, así que fallan al instante con
126# «no pude cargar la dep de build 'zlib' de 'git' (recipes/incoming/.deferred/zlib.toml)».
127# Son 25 y cada una gasta un intento. Intentarlas no es sólo inútil: mete 25 FALLA en el log que
128# parecen roturas del corpus y esconden las de verdad.
129todas = []
130for dirpath, _, files in os.walk("recipes"):
131 if ".deferred" in dirpath.split(os.sep):
132 continue
133 todas += [os.path.join(dirpath, f) for f in files if f.endswith(".toml")]
134todas.sort()
135# El nombre del artefacto es el campo `name`, no el del fichero (17 recetas difieren), pero para
136# ORDENAR alcanza el basename: equivocarse acá sólo cambia la prioridad, nunca la corrección.
137pri = [f for f in todas if os.path.basename(f)[:-5] in prioritarios]
138resto = [f for f in todas if os.path.basename(f)[:-5] not in prioritarios]
139with open(sys.argv[1], "w") as fh:
140 fh.write("\n".join(pri + resto) + "\n")
141print(f" orden: {len(pri)} de perfil primero, {len(resto)} de catálogo después")
142PY
143fi
144mapfile -t RECETAS < "$ORDEN"
145
146# ── PARTICIÓN ENTRE WORKERS: SHARD=i/N ──────────────────────────────────────────────────────────
147# Con un worker el corpus son ~7 días (medido: ~9 min/receta sobre 1161). Repartirlo es la única
148# forma de bajarlo, y no hace falta planificador central: cada worker construye SU parte y las deps
149# que le falten se las construye `hammer build` solo. Al cosechar, todo converge en el mismo store
150# porque las direcciones coinciden — eso está PROBADO por el paso 4 de `farm-lab-sync.sh`, no
151# supuesto.
152#
153# El reparto es por ÍNDICE (i-1, i-1+N, i-1+2N…), no por bloques contiguos: el orden ya está por
154# objetivo, así que bloques contiguos le darían a un worker todo el tramo GUI —el más lento— y a
155# otro sólo hojas rápidas. Intercalando, los cuatro avanzan por el mismo terreno a la vez.
156#
157# ⚠ HAY TRABAJO DUPLICADO y es deliberado: si a dos workers les tocan recetas que cuelgan de
158# `qtbase`, los dos lo construyen. Sale a cuenta igual frente a serializar, pero no esperes
159# aceleración lineal — con 4 workers no son 4×, son ~3×.
160if [ -n "${SHARD:-}" ]; then
161 SH_I="${SHARD%%/*}"; SH_N="${SHARD##*/}"
162 case "$SH_I$SH_N" in *[!0-9]*|"") echo "SHARD debe ser i/N (ej. 2/4)" >&2; exit 1;; esac
163 [ "$SH_I" -ge 1 ] && [ "$SH_I" -le "$SH_N" ] || { echo "SHARD fuera de rango: $SHARD" >&2; exit 1; }
164 SUB=(); k=0
165 for f in "${RECETAS[@]}"; do
166 [ $(( k % SH_N )) -eq $(( SH_I - 1 )) ] && SUB+=("$f")
167 k=$((k + 1))
168 done
169 RECETAS=("${SUB[@]}")
170 LOG="${LOG%.log}-$SH_I-de-$SH_N.log" # un log por shard, o se pisan entre workers
171 echo "== SHARD $SH_I/$SH_N ⇒ ${#RECETAS[@]} recetas de este worker" | tee -a "$LOG"
172fi
173echo "== $(ts) reconstrucción: ${#RECETAS[@]} recetas · libre $(libre_gb) G" | tee -a "$LOG"
174
175if [ "$SECO" = "1" ]; then
176 pend=0
177 for f in "${RECETAS[@]}"; do
178 "$HAMMER" --store "$STORE" hash "$f" --check >/dev/null 2>&1 || pend=$((pend+1))
179 done
180 echo " en deuda: $pend de ${#RECETAS[@]}" | tee -a "$LOG"
181 exit 0
182fi
183
184exec 9>"$LOCK"
185echo "== esperando el lock de build…" | tee -a "$LOG"
186flock 9
187echo "== $(ts) lock tomado" | tee -a "$LOG"
188
189ok=0; falla=0; cache=0; i=0
190for f in "${RECETAS[@]}"; do
191 i=$((i+1))
192 n="$(basename "$f" .toml)"
193
194 if "$HAMMER" --store "$STORE" hash "$f" --check >/dev/null 2>&1; then
195 cache=$((cache+1)); continue
196 fi
197
198 l=$(libre_gb)
199 if [ "${l:-0}" -lt "$ABORT_GB" ]; then
200 echo "!! $(ts) ABORTO: quedan ${l} G (< ${ABORT_GB}). Sellado $ok, fallo $falla." | tee -a "$LOG"
201 exit 1
202 fi
203 if [ "${l:-0}" -lt "$MIN_LIBRE_GB" ]; then
204 # Seguro por construcción: acá NO hay ningún build corriendo (somos el único, y en serie).
205 echo " [$(ts)] libre ${l} G < ${MIN_LIBRE_GB} ⇒ purgo work/sources y work/repos" | tee -a "$LOG"
206 rm -rf work/sources/* 2>/dev/null
207 # `work/repos` son los clones `--mirror` de gitea de las recetas hub-only. Se re-clonan; el coste
208 # es red, no trabajo perdido.
209 rm -rf work/repos/* 2>/dev/null
210 # El registry de cargo es lo ÚLTIMO, y va con guarda: el act-runner de tawasuyu corre como el
211 # mismo usuario y comparte este `~/.cargo`. Purgarlo con un `cargo` ajeno en vuelo le arranca los
212 # ficheros a SU compilador — el mismo accidente del kernel a medias, sólo que en el vecino. Se
213 # pregunta por el CÓDIGO DE SALIDA de `pgrep`, nunca por su stdout.
214 if [ "$(libre_gb)" -lt "$MIN_LIBRE_GB" ]; then
215 if pgrep -x cargo >/dev/null 2>&1 || pgrep -x rustc >/dev/null 2>&1; then
216 echo " sigue corto, pero hay cargo/rustc ajeno en vuelo ⇒ NO toco $CARGO_H/registry" | tee -a "$LOG"
217 else
218 echo " sigue corto ⇒ purgo $CARGO_H/registry (caché, se re-descarga)" | tee -a "$LOG"
219 rm -rf "$CARGO_H"/registry/cache/* "$CARGO_H"/registry/src/* 2>/dev/null
220 fi
221 fi
222 echo " libre tras purgar: $(libre_gb) G" | tee -a "$LOG"
223 fi
224
225 t0=$(date +%s)
226 if "$HAMMER" --store "$STORE" build "$f" >>"$ROOT/work/reconstruccion-detalle.log" 2>&1; then
227 ok=$((ok+1)); estado=OK
228 else
229 falla=$((falla+1)); estado=FALLA
230 fi
231 printf '%s [%4d/%4d] %-6s %-40s %4ds libre %sG\n' \
232 "$(ts)" "$i" "${#RECETAS[@]}" "$estado" "$n" "$(( $(date +%s) - t0 ))" "$(libre_gb)" | tee -a "$LOG"
233done
234
235echo "== $(ts) FIN · sellado $ok · fallo $falla · ya-estaba $cache · libre $(libre_gb) G" | tee -a "$LOG"