#!/usr/bin/env bash # poda-fuentes.sh — recolector de `work/sources/`. Hermano de `store-gc.sh`, pero el criterio no # tiene nada que ver, y confundirlos sería caro. # # ── QUÉ SE ESTABA ACUMULANDO ──────────────────────────────────────────────────────────────────── # Medido el 2026-08-31 en gioser: `work/sources` pesaba **79 G** — 106 árboles, media de 745 MB, # los peores `pixi` 3,5 G y siete apps cosmic por encima de 3 G cada una. El volumen del store # (`/dev/sdb`, 246 G) estaba al **97%, 8,8 G libres**, con la granja escribiendo ahí. # # Y esos 3,5 G no son fuente: el árbol de `work/sources/-` es a la vez el sitio donde # se extrae Y donde se compila (takana parchea y vendorea in situ). Lo que engorda es el `target/` # de cargo, los `.o`, el `vmlinux`. Es RESIDUO DE BUILD, no código. # # ── POR QUÉ BORRARLO NO CUESTA NADA (esto es lo que hace segura la poda) ──────────────────────── # No es una caché. `takana-build/src/fetch.rs` tiene exactamente dos caminos —`fetch_git` (línea # 73) y `fetch_tarball` (línea 234)— y **los dos hacen `remove_dir_all` del árbol y lo re-extraen, # incondicionalmente, en cada build**. No hay ni una rama que reutilice un árbol existente. # # ⇒ Borrar un árbol no cuesta ni la re-extracción: ésa ocurre igual la próxima vez que se construya # esa receta. Lo ÚNICO que se pierde es poder mirar por dentro el último build de esa receta # —el post-mortem de uno que falló—, y ese valor se muere en un día. # # ── POR QUÉ TOMA `work/.farm-build.lock` (y no es opcional) ───────────────────────────────────── # El único modo de que esto haga daño es borrar el árbol que un `bwrap` está usando para compilar: # el ADR 0012 en su forma más directa, y el mismo desastre medido en CLAUDE.md §1 (93 de 205 # recetas KDE muertas con `cc is not a full path`). El lock es el punto de serialización que ya # usan `farm-worker-loop.sh` y `campana-deuda.sh`, así que tomarlo acá nos serializa con la granja. # # ⚠ Y hace falta DE VERDAD, no por prudencia: el filtro es por mtime del directorio raíz, que sólo # cambia cuando se añaden o quitan entradas de primer nivel. Un build largo en vuelo puede tener # un raíz con mtime de hace dos días mientras compila abajo. El mtime NO distingue "viejo" de # "lento". El lock sí. # # ── POR QUÉ EL SUELO ES EN HORAS Y NO EN DÍAS ─────────────────────────────────────────────────── # La lección de `cache-ci-no-envejece`: aquel cron podaba a +10 días sobre datos que se reescriben # enteros cada ráfaga, o sea que liberaba CERO y parecía que protegía. **Un guardián calibrado a # una escala que el dato nunca alcanza no protege.** Acá los 106 árboles abarcaban 3 días (76 de # uno solo) ⇒ un umbral en días grandes no habría tocado nada. 24 h deja el post-mortem del día y # se lleva el resto. # # ── POR QUÉ NO SE CONDICIONA A ESPACIO LIBRE ──────────────────────────────────────────────────── # Porque borrar no cuesta nada. Un umbral por espacio ("podar sólo bajo 20 G") deja que la basura # se acumule hasta rozar el borde y convierte una poda barata y plana en un pico cerca del límite, # que es justo cuando un build en vuelo puede quedarse sin disco a mitad. Se mantiene plano. # # Uso: scripts/poda-fuentes.sh [--aplicar] [--horas N] # (sin --aplicar es DRY-RUN: mide y no borra) set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" # ── ⚠ DÓNDE ESTÁ `sources` SE DERIVA, NO SE SUPONE (arreglado 2026-10-21) ────────────────────── # Esto decía `FUENTES="${FUENTES:-work/sources}"` a secas, y **por eso el disco llegó al 100 %**: # el 2026-10-15 `work_root` dejó de ser `dirname(store)/work` cuando el store es un punto de # montaje, y pasó a ser `/.work` (`crates/takana-build/src/config.rs`, `work_root_para`). # En esta caja eso son `/store/.work/sources` — y el podador seguía mirando `work/sources`, que no # existe. Salía «no existe work/sources — nada que podar», con 14,9 G acumulados al lado y # `/store` con 264 K libres. Un podador que no encuentra su montón **informa éxito**, que es la # peor forma de fallar. # # ⇒ Se replica la MISMA regla que el build, en este orden: # 1. `$FUENTES` explícito, si alguien lo pasa # 2. `$TAKANA_WORK/sources`, el override deliberado que takana respeta # 3. si el store es punto de montaje ⇒ `/.work/sources` # 4. si no ⇒ `dirname(store)/work/sources` # El punto de montaje se detecta comparando el número de dispositivo con el del padre, que es lo # que hace `store_es_punto_de_montaje` y no necesita `mountpoint(8)` (que acá no está). STORE_POD="${HAMMER_STORE:-${TAKANA_STORE:-${STORE:-/store}}}" derivar_fuentes() { [ -n "${TAKANA_WORK:-}" ] && { echo "$TAKANA_WORK/sources"; return; } if [ -d "$STORE_POD" ]; then d_store=$(stat -c %d "$STORE_POD" 2>/dev/null || echo x) d_padre=$(stat -c %d "$(dirname "$STORE_POD")" 2>/dev/null || echo y) if [ "$d_store" != "$d_padre" ]; then echo "$STORE_POD/.work/sources"; return; fi echo "$(dirname "$STORE_POD")/work/sources"; return fi echo "work/sources" } FUENTES="${FUENTES:-$(derivar_fuentes)}" # Y si la ruta derivada no existe pero la VIEJA sí, se usa la vieja y se avisa: así una caja que # todavía no haya migrado se sigue podando en vez de quedarse muda. if [ ! -d "$FUENTES" ] && [ -d work/sources ]; then echo "⚠ $FUENTES no existe; cayendo a work/sources (¿caja sin migrar?)" >&2 FUENTES=work/sources fi HORAS="${HORAS:-24}"; APLICAR=0; ESPERA="${ESPERA:-60}" while [ $# -gt 0 ]; do case "$1" in --aplicar) APLICAR=1 ;; --horas) HORAS="$2"; shift ;; *) echo "opción desconocida: $1" >&2; exit 2 ;; esac shift done [ -d "$FUENTES" ] || { echo "no existe $FUENTES — nada que podar"; exit 0; } gigas() { echo "$1" | awk '{printf "%.1f", $1/1073741824}'; } # `-mmin +N` en vez de `-mtime`: `-mtime +0` es ">24h" por redondeo hacia abajo y no deja expresar # nada más fino. Con minutos el umbral se dice tal cual. MINUTOS=$((HORAS * 60)) poda() { local libre_antes libre_despues n # ⚠ `df -B1 --output=avail` es GNU y **busybox no lo tiene** (medido en la caja takana el # 2026-09-17). `df -k` + awk da los mismos bytes y anda en los dos mundos. libre_antes=$(df -k "$FUENTES" | tail -1 | awk '{print $4*1024}') # ⚠ NADA DE `<(...)` ACÁ. La sustitución de procesos de bash necesita `/dev/fd`, y una caja # takana **no lo tiene** (gioser sí: `/dev/fd -> /proc/self/fd`). El síntoma no nombra la causa: # `line 72: /dev/fd/63: No such file or directory` y después `viejos: unbound variable`, porque el # `mapfile` nunca corrió. Un fichero temporal anda en cualquier shell y en cualquier /dev. local lista; lista=$(mktemp) find "$FUENTES" -mindepth 1 -maxdepth 1 -type d -mmin "+$MINUTOS" | sort > "$lista" viejos=() while IFS= read -r _l; do [ -n "$_l" ] && viejos+=("$_l"); done < "$lista" rm -f "$lista" n=${#viejos[@]} local quedan quedan=$(find "$FUENTES" -mindepth 1 -maxdepth 1 -type d -not -mmin "+$MINUTOS" | wc -l) echo "── poda de $FUENTES · suelo ${HORAS}h · libres $(gigas "$libre_antes") G" echo " $n árbol(es) por encima del suelo · $quedan se quedan (post-mortem del día)" [ "$n" -eq 0 ] && { echo " nada que podar"; return 0; } if [ "$APLICAR" -eq 0 ]; then # El dry-run NO mide con `du`: recorrer 79 G de árboles cuesta minutos y este script corre # dentro del lock. Se nombran los candidatos y se deja el tamaño para el --aplicar, que lo saca # gratis del `df` de antes y después. printf ' · %s\n' "${viejos[@]}" | head -20 [ "$n" -gt 20 ] && echo " … y $((n - 20)) más" echo " DRY-RUN: no se borró nada (usar --aplicar)" return 0 fi printf '%s\0' "${viejos[@]}" | xargs -0 -r rm -rf libre_despues=$(df -k "$FUENTES" | tail -1 | awk '{print $4*1024}') echo " borrados $n · libres $(gigas "$libre_despues") G · LIBERADO $(gigas $((libre_despues - libre_antes))) G" } # `-w`: si hay un build en vuelo NO se espera indefinidamente. Este script lo llama el cron cada 30 # min; quedarse colgado del lock durante un kernel de 2 h dejaría al cron sin cosechar. Saltar un # ciclo no cuesta nada porque la basura no se va a ningún lado. exec 9>work/.farm-build.lock if ! flock -w "$ESPERA" 9; then echo "── poda de $FUENTES: build en vuelo (lock tomado tras ${ESPERA}s) ⇒ este ciclo NO toca" exit 0 fi poda