`Restart=always` + `RestartSec=30` sin límite: si una receta no entra en la RAM de la
caja, el OOM killer la mata, systemd relanza a los 30 s, el loop recorre la cola por glob
alfabético y vuelve a la MISMA receta. En el LXC dev.gioser.net eso duró 30 horas con
`clang18`, y dejó la máquina con presión de I/O `full avg300=43` —todo bloqueado en disco
casi la mitad del tiempo— y sshd incapaz de completar el banner. Diagnosticarla desde
fuera era imposible: parecía red o disco. El único rastro eran dos `oom-kill` en el
journal, que sólo se ven desde dentro.
StartLimitBurst=3 / StartLimitIntervalSec=3600: a los 3 arranques en una hora systemd se
rinde y deja la unidad en `failed`, VISIBLE en `systemctl status`. OOMPolicy=stop: un OOM
no es un fallo transitorio, si no entra ahora no entra en 30 segundos.
Rendirse no pierde nada: el hub ya tolera workers ausentes —cosecha-cron dice "siembra
falló" y sigue con el siguiente— y un worker parado y diagnosticable vale más que uno que
se reinicia para siempre.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
El gate de SDD 25 §4.ter 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, porque el kernel de la
máquina de trabajo sí trae MEMCG: sólo se ve comprobando el artefacto.
Mismo cable que el vigía de fuentes y por la misma razón que está escrita ahí arriba: lo que
nadie refresca envejece hacia el optimismo. Ahora cada ciclo deja el veredicto en
docs/state/kernel-contract.txt y el git log lo muestra como el resto del khipu.
El fichero 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».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ACUcwo9mZsE5ocYVE9npih
En Debian `patch` entra de regalo dentro de `build-essential`; al desglosar la rama dnf a
gcc/g++/make sueltos se cayó sin que nadie lo notara. El campo `patches = [...]` de una
receta lo aplica hammer DEL LADO DEL HOST, fuera del sandbox, así que no alcanza con que
el rootfs lo traiga —lo trae, `/usr/bin/patch` está en el alpine de los dos labs— y por
eso el diagnóstico "rootfs laptop ≠ worker" no aplicaba acá.
Medido en dev.gioser.net: `cairo-shared` moría con `spawn patch: No such file or
directory` y detrás caían `pango` y `gtk4`. El stack GUI entero de GNOME por un binario
de 129 KB ausente en el host.
Regla que queda escrita: toda herramienta que hammer invoque HOST-SIDE va en esta lista,
no en `[deps]` de la receta — declararla en la receta re-hashearía cairo y todo GNOME
debajo, para arreglar algo que no es de la receta.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
Es el mismo fósil que el propio script documenta para GNOME, repetido con las colas
que nacieron después. Las 25 recetas en deuda de COSMIC no fallaban: nadie las
intentaba, porque su cola no estaba en la lista. Una cola ausente no da error, da
silencio — el mismo modo de fallo que "el corpus no está en las QUEUES del worker".
Se añade la regla en el comentario: cola nueva y línea QUEUES, en el mismo commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
La cabecera decía 'def 408909310' y la línea 38 usa 417847948 desde el 2026-08-08.
La 408909310 es justo la que NO traía rust ni go — la que dejaba fuera al 76% del
catálogo — así que el comentario apuntaba a la imagen rota.
Un comentario que nombra un id concreto es una referencia, no prosa: si alguien lo
lee y re-apunta IMAGE a mano, revive el bug que el propio fichero documenta debajo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
Un LXC prestado (dev.gioser.net, Fedora 44, 6 núcleos, 196 G) pasa a ser el worker
mientras esté disponible; el camino Debian queda intacto para seguir abriendo
workers efímeros en Hetzner.
Cambios:
- familia detectada por gestor de paquetes (apt | dnf5/dnf) con sus equivalencias
(build-essential -> gcc/g++/make, libssl-dev -> openssl-devel, uidmap ->
shadow-utils, xz-utils -> xz).
- el veredicto de userns deja de ser "escribí el sysctl" y pasa a ser "unshare -Umr
funciona de verdad": en un contenedor el sysctl es read-only y no hace falta,
porque el userns lo concede el host. Si no se puede crear, aborta: sin eso la caja
no puede ser worker.
- swapon degrada a AVISO RUIDOSO en vez de abortar. En LXC devuelve Operation not
permitted (medido) y no hay forma desde dentro; el swap lo da el host. Se dice
explícito que sin swap el techo de RAM es duro y el OOM killer no avisa antes.
- documenta los dos pasos que una caja virgen necesita del hub y que la golden
escondía: instalar rsync a mano, y que el HUB EMPUJE el lab ya verificado en vez
de que el worker lo baje del Storage Box (el worker no debe tener secretos).
Validado provisionando dev.gioser.net de cero: hammer compila, el lab extrae y
verifica por sha256, y el toolchain queda "idéntico al lock (108 paquetes)".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LykB7vw38b4Ck1Zij15RQ4
El 2026-08-28 `/dev/sda2` llegó al 100% —CERO bytes— porque
`/home/sergio/go/pkg/mod` pasó de 498 MB a 25 G construyendo las recetas Go.
El vigía miraba store/work-sources/CARGO_HOME y no eso, así que no avisó.
Los builds Go morían con `write /home/sergio/go/pkg/mod/cache/download/…: no
space left on device`, que no se lee como un fallo de disco del build porque la
ruta no está en el repo (httpx, hugo, impl…). Y un `/` lleno no rompe una
tanda: rompe la máquina (gitea, caddy).
GOPATH se movió a /mnt/cosecha/gopath por symlink, igual que ~/.cargo. Se añade
al vigía igualmente, para que lo vea si alguien lo devuelve a `/`.
El driver marca FALLA en la receta que invocó, pero `hammer build` construye
deps recursivamente: si una dep muere, la FALLA sale a nombre del dependiente.
El 2026-08-27 siete recetas figuraban rotas (xdg-desktop-portal, gnome-desktop,
gjs, gdm, gnome-session, gnome-settings-daemon, gnome-shell) y ninguna lo
estaba: los 1978 errores los emitía gdk-pixbuf. Los tiempos de 2-10 s son la
firma — demasiado rápido para ser un build propio.
Método: quitar ANSI, partir el log por los marcadores `fetch … name=X` y
atribuir cada error al último fetch anterior. Ya destapó dos causas reales
(gdk-pixbuf/gif y gtk4/PIC) y desinfló la lista de la frontera _ssl, donde
`No module named` lo emite SÓLO spidermonkey.
El store de hammer se mudó al volumen harkaq-cosecha (/dev/sdb, bind-mount
sobre ./store, work/sources y work/repos; CARGO_HOME también). Desde eso,
medir `$ROOT` mide /mnt/vvv, que hammer COMPARTE con tawasuyu y que ya no
es donde gasta.
Por qué se movió: tawasuyu repuebla su target a ~62 G/h. El 27/08 se le
liberaron 92,9 GiB con cargo clean, se lanzó la campaña con 64 G libres y
70 min después el disco estaba en 237 MB — la tanda abortó en cosmic-applets
por un disco que no era suyo. El cargo clean sirve para un pico, no para
financiar una campaña larga: el vecino lo recupera entero en una hora.
El vigía ahora toma el mínimo de {STORE, work/sources, CARGO_HOME}. Con
$ROOT veía 46 G y bajando; ahora ve 84 G reales.
Su commit es 000…0 a propósito y no se puede espejar nunca. Un ✗ permanente en cada tanda entrena a
no mirar los rojos, y entonces el que sí importa pasa desapercibido.
Mirror git completo: 570/571 commits, 2,3 G. El que falta es ese placeholder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
diffutils, findutils-xargs y kustomize pinean el SHA de un tag, no de un commit. Rompía las dos
puntas por la misma suposición: el poblador apuntaba una rama al tag (imposible) y hammer escribía
ese SHA en el fichero shallow (que sólo admite commits).
El poblador pela con ^{commit} y manda el objeto tag aparte en refs/tags/hammer-objeto; sin él el
cat-file -e del otro lado no encuentra lo que la receta pide. hammer lee la frontera del bundle con
git bundle list-heads, que no necesita los objetos, y trae refs/*:refs/*.
Los 339 bundles del formato viejo siguen sirviendo: comprobado con una receta de cada forma contra
un repo que no resuelve por DNS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
Imprimía siempre «upstream no da el commit» pasara lo que pasara. En la primera tanda marcó así a
diffutils, findutils-xargs y kustomize, cuyos commits se traen a mano sin problema: era transitorio.
Ahora bundle() devuelve (ruta, motivo) con el stderr del paso que falló.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
Los 606 repos por commit son el 52% de las fuentes y el mirror de tarballs no los cubría. Se espejan
como `hammer/fuentes-git/{commit}.bundle` — 571 commits distintos, porque hay commits compartidos
entre colas y se espeja uno solo.
La identidad es el commit y la verificación la hace git: al desempaquetar comprueba cada objeto
contra su SHA, así que un bundle alterado no pasa. No hace falta índice ni sha256 aparte.
SHALLOW, NO CLONES COMPLETOS. El bundle sale de un `fetch --depth 1` del commit exacto: para `act`
son 9,3 MB en vez del repo entero, y con 571 fuentes eso decide si el mirror cabe. Es legítimo
porque hammer NUNCA usa la historia — lo único que hace con un repo es `git archive <commit> |
tar -x`, materializar un árbol.
EL DETALLE QUE COSTÓ ENCONTRAR. Un bundle hecho desde un repo shallow no lleva la frontera de
historia, y al desempaquetarlo git aborta con «Failed to traverse parents … did not send all
necessary objects». El mensaje dice que faltan objetos y es ENGAÑOSO: llegan enteros —`git archive`
ya funciona pese al error—; lo que falta es decirle a git dónde termina la historia. Se escribe el
propio commit en `<destino>/shallow` antes del fetch. No hay nada que transportar: la frontera de un
`--depth 1` es exactamente ese commit.
Y UN BUG PROPIO QUE VALE DOCUMENTAR: se pasaba al `git fetch` la ruta RELATIVA del bundle, y como
`run_git` invoca `git -C <destino>`, git la resolvía dentro de `<destino>`. Como el fallo del mirror
se traga a propósito para caer a upstream, el síntoma salía lejísimos: el build moría con «commit …
no existe en <repo> tras fetch», culpando a upstream de un error de ruta local. Ése es el precio de
que el mirror falle en silencio, y por eso el silencio se paga con comentarios explícitos.
Verificado igual que el de tarballs, con receta EFÍMERA para que no haya cache-hit: commit de `act`
ya espejado + un repo cuyo host no resuelve por DNS. Sin HAMMER_MIRROR_GIT falla en el clone; con
él, sella — y el artefacto trae el README.md real de act.
`cargo test -p hammer-build`: 5/5. Población de los 571 bundles corriendo aparte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
`rsync` (404 de samba.org) y `musl` (musl.libc.org no responde) no se pueden construir hoy, y no por
culpa nuestra. Es el estado estacionario: una distro que construye TODO desde fuente tiene tantos
puntos de fallo como fuentes, y son servidores de terceros que nadie nos prometió mantener.
LA MEDIDA, peor de lo que parecía. Sobre 1167 fuentes (561 tarball + 606 git, 79 hosts):
github.com sostiene 742 — el 64% del corpus depende de UN host. Doce hosts sostienen el 89%. Y 43
hosts sostienen exactamente UNA receta cada uno: ahí es donde muerde el bit-rot lento.
El vigía, en su primera corrida: 8 URLs muertas de 1167. Tres de ellas —busybox, freetype,
freetype-shared— están SELLADAS Y EN USO: son el shell y las fuentes del escritorio que se capturó
hoy. Se salvan sólo porque el tarball sigue en la caché local de esta máquina.
LA URL NUNCA FUE LA IDENTIDAD, y el código ya lo sabía: `hash_inputs` usa `tarball:{sha256}` /
`git:{commit}` y el `..` descarta la URL; la caché se nombra `{sha256}.tar` con un comentario que
dice literalmente que cambiar de mirror no la invalida. Añadir mirrors NO re-hashea NADA. Faltaba el
mecanismo, no el diseño.
Orden: caché local → mirror propio → upstream. El mirror va ANTES, no como rescate: el sha256 se
verifica igual, así que no hay diferencia de contenido posible, y un mirror que sólo se usa cuando
upstream falla es un mirror que nadie prueba — se descubre roto el día que hace falta.
LO QUE HAY QUE HACER BIEN. Un mirror que sirve calladamente lo que upstream perdió convierte un
fallo ruidoso en silencio. Por eso construir y vigilar van SEPARADOS: `hammer build` nunca avisa
(sería ruido en 561 recetas), y `fuentes-vigia.sh` pide cabeceras, escribe
docs/state/fuentes-vigia.json y lo corre el latido. Sin ese contrapeso las URLs se mueren una a una
y el corpus queda irreconstruible con todo en verde — el mismo modo de fallo que dejó el grafo de
wlr 17 días anunciando un 121/121 falso.
⛔ PROHIBIDO cambiar el sha256 para "arreglar" una URL muerta. Es la tentación natural ante un 404 y
no arregla una descarga: cambia lo que la distro construye. Otro sha256 es otro contenido, y la
receta seguiría diciendo `rsync 3.4.4` mientras construye otra cosa.
VERIFICADO DE PUNTA A PUNTA. El primer intento —construir busybox con el mirror puesto— dijo BUILD
OK y NO PROBÓ NADA: cache-hit del artefacto, cero bytes descargados. La prueba válida usa una receta
efímera con un sha256 que sí está en el mirror y una URL que ni resuelve por DNS. Sin HAMMER_MIRROR:
`curl (6) Could not resolve host`. Con él: sella, con el contenido real.
Mirror poblado: 126 objetos en el Storage Box que ya se paga. `cargo test -p hammer-build`: 5/5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
`dejavu-fonts` y `xkeyboard-config` entran en `recipes/incoming-wlr/` y en las raíces de
`escritorio-sway`. La clausura pasa de 123 a 125 y ambas ya estaban selladas: cero builds.
POR QUÉ TENÍAN QUE SER RAÍCES Y NO PODÍAN LLEGAR POR CLAUSURA. Son DATOS, no binarios: ninguna
receta depende de una fuente ni de un mapa de teclado para COMPILAR. El grafo puede seguir todas las
aristas que quiera y no va a alcanzarlas nunca. Si no se declaran, no están — y el perfil sigue
dando 100%, porque mide la clausura de las raíces declaradas.
Copias BYTE-IDÉNTICAS a las de incoming-kde, a propósito. Ya había tres copias iguales de
xkeyboard-config (kde, gnome, cosmic) y dos de dejavu-fonts (kde, cosmic); ésta es la cuarta. No se
consolidan al corpus ahora porque eso toca las colas de otros tres frentes que otros agentes
trabajan. Mantenerlas idénticas deja esa consolidación como un `git mv` trivial en vez de un merge:
cuatro ficheros iguales se unifican de un tirón, cuatro casi-iguales exigen revisión.
VERIFICADO de punta a punta, no por el log: rootfs rehidratado DESDE CERO con sólo lo que la
clausura declara (123/123), y `sway-headless.sh` sin una línea de inyección manual de fuentes ni
XKB. Cero errores de xkbcommon, cero de fcft. La captura resultante tiene el MISMO sha256 que la de
ayer con todo inyectado a mano — 1280x720, 383 colores. La declaración es exactamente equivalente a
la inyección, y el pipeline reproduce.
Queda como deuda real (no de declaración): libz.so.1 / libexpat.so.1 / libffi.so.8, que sway pide
por estar enlazado dinámico y el corpus sólo produce como `.a`. Y `fonts.conf` NO era deuda: lo trae
el propio artefacto de fontconfig.
Perfil: escritorio-sway 123/125. Los 2 que faltan son strace (choca con los linux-headers del lab) y
rsync (404 de upstream), ninguno del escritorio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
`scripts/wlr/sway-headless.sh` corre el perfil escritorio-sway dentro de un bwrap, sin QEMU, sin
kernel y sin imagen de disco, y captura con grim. Evidencia: PNG 1280x720 con 383 colores —#242424
fondo de foot, #285577/#4c7899 bordes de sway, #d0d0d0 el texto. sway 1.10 / wlroots 0.18.2 /
foot 1.27.0, todo del corpus.
Sirve porque el ciclo de diagnóstico pasa de ~40 min (product-rootfs + imagen EFI + arranque) a ~2
min. No sustituye al arranque en QEMU: no prueba kernel, initramfs, DRM ni PID1. Prueba lo que el
arranque en VM tapa detrás de una pantalla negra.
LO QUE SE MIDIÓ, y es lo que importa: el perfil da 121/121 y aun así NO arranca solo. Faltan cinco
cosas, ninguna en la clausura:
· libz.so.1 / libexpat.so.1 / libffi.so.8 — sway salió DINÁMICO y el corpus sólo produce `.a`.
Sin ellas ni arranca. libz existe como artefacto `zlib-shared`; las otras dos hoy sólo están en
`.dev-fs/alpine`, o sea que el escritorio depende del rootfs del lab.
· xkeyboard-config y dejavu-fonts — HAY receta de las dos, en incoming-{kde,gnome,cosmic}. En la
cola de wlr no, así que el perfil no las alcanza. Un escritorio sin una sola fuente.
· /etc/passwd y /etc/fonts/fonts.conf.
Y bash NO CORRE: enlazado dinámico contra ncurses, del que sólo hay `libncurses.a` ⇒ «Error
relocating /bin/bash: tgetent: symbol not found».
El modo de fallo vale más que la lista. Nada de esto se vio como un error: `foot` abría, sway
registraba «New xdg_shell toplevel» —la ventana EXISTÍA en el árbol— y la captura salía con UN SOLO
COLOR. El shell moría al instante y la ventana se cerraba antes del frame. Log entero en verde,
pantalla vacía. Por eso la evidencia es contar colores del PNG y nunca leer el log: 1 color = sólo
swaybg, ningún cliente dibujó.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
`build-state.py --wlr` existe desde que se abrió el frente, y su propio comentario dice que sin él
el frente es INVISIBLE para el khipu. La línea nunca se agregó a `cosecha-cron.sh`: el latido
regeneraba {base,kde,gnome,cosmic} y saltaba wlr.
Medido: build-state-wlr.json quedó fijo el 2026-08-09 anunciando `escritorio-sway 121/121`. Hoy,
regenerado, da 108/123. En el medio se re-hashearon freetype, make, python3 y libpng-pic, que
arrastraron a deuda a 14 dependientes (fuzzel, yambar, swaybg, swaylock, slurp, sed, strace, tzdata,
rsync, pciutils, procs, sd, skim, tokei). Nadie lo vio porque el número que se mira estaba perfecto.
Un grafo que nadie regenera no envejece en cualquier dirección: envejece hacia el OPTIMISMO. Sólo
puede sobreestimar lo sellado, porque el paso del tiempo únicamente invalida hashes, nunca los crea.
Va también `foot` a las raíces de escritorio-sway. El comentario de targets.toml afirmaba que entraba
por la clausura de `cli`; el grafo lo desmiente — `perfil.cli` no lo lista y ningún perfil lo
arrastraba. El escritorio daba 121/121 SIN EMULADOR DE TERMINAL. Se vio al hidratar la clausura para
armar la imagen, no antes: la métrica mide la clausura de las raíces DECLARADAS, y es estructuralmente
ciega a lo que falta en la declaración.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v9ozVm44p6DB7EMXeZK4o
El fichero de orden estaba fijo en la linea 69 y lo regeneraba el bloque python
en cada corrida, asi que no habia forma de pasarle una lista concreta. Con el
grafo ya corregido quedan 17 recetas que cierran las SEIS imagenes (base, cli,
sway, mirada, cosmic, gnome) y ninguna esta bloqueada; correr el corpus entero
para llegar a ellas es absurdo.
Ahora `ORDEN_IMPUESTO=1 ORDEN=<fichero>` usa la lista del que llama y hereda
gratis lo que hace util a este script: el flock de la regla 1, el vigia de disco
de dos sistemas de ficheros y el log por receta.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5DComN9Nu47TBpYMBHBn
La noche del 2026-08-22 la campana corrio sharded en dos workers. Un volumen de
Hetzner se adjunta a UN server, y este script crea N workers en un bucle
apuntando todos al MISMO VOL_NAME: hworker-1 tomo harkaq-cosecha y para
hworker-2 no quedaba nada que adjuntar. Construyo el shard 1/2 entero en su
disco raiz y el dead-man se lo llevo a las 03:27Z. 21 G, 397 artefactos
sellados; 584 de sus 675 no existen en ningun otro sitio (work/perdidos-hworker2.txt).
Lo caro es que la deteccion YA ESTABA y era correcta:
echo " ⚠ el store NO quedó en el volumen ⇒ lo que construya se PIERDE..."
Ese aviso se imprimio, con esas palabras, y el script siguio adelante: armo el
dead-man y sembro la cola igual. Un aviso que no detiene el pipeline no es un
guardian cuando no hay nadie leyendo el log. Es la regla 3 del CLAUDE.md con
otra cara: el ausente (el volumen) llego hasta el final diciendo que todo fue
bien.
Tres cambios, todos sobre el mismo fallo:
- `hcloud volume attach` dejaba de tragarse el error. Estaba escondido dos
veces: `>/dev/null 2>&1` el mensaje y `|| true` el codigo de salida.
- Se pregunta ANTES quien tiene el volumen tomado, y el motivo lo nombra.
- Los dos ⚠ finales pasan a ser `sin_volumen`, que NO siembra: borra el server
recien nacido (vacio, para que no quede idle facturando como hworker-4) y
sale 1. Mismo blindaje que el dead-man y farm-down: solo borra con label
role=hammer-worker, verificado que gioser no matchea.
Escape explicito para el caso deliberado: SIN_VOLUMEN_OK=1.
Verificado en negativo, que es como se comprueba lo que un guardian IMPIDE: la
llamada corta con exit 1 sin alcanzar la linea siguiente, y con un server sin
label no borra nada.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5DComN9Nu47TBpYMBHBn
Hoy `--aplicar` borro los 256 superados y acto seguido murio con
scripts/store-gc.sh: line 143: work/store-gc-superados.txt: No such file or directory
Esa combinacion —borrado hecho, ledger sin escribir— es exactamente la que
reabre el bucle churn que 2026-08-07 costo 362 artefactos y 24 G: `farm-sync`
usa ese fichero como --exclude-from, y sin el la proxima cosecha del worker nos
devuelve lo mismo que acabamos de borrar. El sintoma seria «dos podas con el
mismo numero», que ya sabemos leer como señal.
El manifiesto de la linea 109 SI se escribio, y la diferencia entre los dos es
que aquel hace `mkdir -p work` antes. Ahora el ledger usa ruta ABSOLUTA
($ROOT/work), hace mkdir+touch, y si aun asi no puede escribirse el script
FALLA RUIDOSAMENTE diciendo que los borrados van a volver y como reconstruir el
registro a mano — en vez de informar exito como hasta ahora.
El ledger de esta tanda queda reconstruido desde su manifiesto (256 entradas).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5DComN9Nu47TBpYMBHBn
Decision del usuario. La reconstruccion en la granja destapo que toda la zona
grafica del corpus muere en cadena con
/usr/include/glib-2.0/glib/gi18n-lib.h:25:10: fatal error: 'libintl.h' file not found
y con ella gdk-pixbuf, appstream, gjs, gdm, mutter, gnome-shell, wireplumber,
sway, swaybg, swaylock, xdg-desktop-portal... El header no estaba en NINGUNA de
las dos maquinas (labs identicos, mismo sha256), asi que no era divergencia de
worker: era el corpus.
En Alpine `libintl.h` NO lo trae musl-dev: lo trae `musl-libintl`, que es
justo lo que faltaba. Verificado: `checking for libintl.h... yes`, y glib,
python3, meson y el resto de la base ya sellan contra el lab nuevo.
El apk va contra EDGE, que es rodante, asi que lo que importa no es solo que
funcione sino que NO ARRASTRE: el lock del toolchain cambia en UNA linea
(+musl-libintl-1.2.6-r2), ni una version movida. La vez anterior un `apk add`
se llevo de propina ncurses 6.5→6.6 y readline 8.3.1→8.3.3.
Se eligio `musl-libintl` y no `gettext-dev` a proposito: el segundo mete `.pc`
que compiten con los del store en la resolucion de pkg-config, que es
exactamente como freetype autodetecto bzip2 y tumbo fontconfig con 27 recetas
detras. Este no trae ninguno.
`openssl-dev` sigue FUERA, respetando el veto de 6ab29b1: el host-tool del
kernel enlaza el openssl del corpus desde el overlay. python3 sigue sin `_ssl`
⇒ gjs/gdm/spidermonkey siguen cayendo por esa via, que es otro frente.
Coste asumido: el corpus se re-hashea otra vez.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5DComN9Nu47TBpYMBHBn
Consulta solo las recetas que HOY no tienen licencia, asi que su cosecha MENGUA
a cada pasada: lo sembrado ayer ya no sale en --faltan. Y reescribia el fichero
entero con la cosecha del dia. Hoy una pasada con CERO detecciones dejo
docs/licencias-detectadas.tsv en 0 filas y se llevo las 610 que documentaban de
donde salia cada licencia ya sembrada.
Salio con exit 0 diciendo «escrito» y «sembrar con». Un vacio que llega hasta el
final afirmando que todo fue bien — la regla 3 de CLAUDE.md, esta vez sobre el
registro de procedencia en vez de sobre un artefacto.
Ahora FUSIONA con lo registrado (la fila nueva gana) y se niega a sobrescribir
si el resultado pierde filas: un registro de evidencia que encoge es un fallo,
no un resultado. Con CON=0 lo dice y no invita a sembrar nada.
Y las cuatro de HashiCorp quedan declaradas: BUSL-1.1.
No es adivinado ni sale de la API —que devuelve NOASSERTION en las cuatro, y por
eso llevaban meses sin licencia—: es el texto del LICENSE del TAG QUE LA RECETA
PINEA, no el de la rama por defecto. consul v1.22.7, vault v1.21.4, nomad
v1.11.3 y packer v1.15.4 abren con «Business Source License 1.1».
⚠ BUSL-1.1 NO es una licencia libre: prohibe el uso en produccion que compita
con el producto de pago, y solo pasa a MPL-2.0 cuatro años despues de cada
version. Cuatro paquetes del catalogo publicable no son redistribuibles como si
fueran libres.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5DComN9Nu47TBpYMBHBn
Media `df` sobre el repo y nada mas. Pero las recetas Rust no gastan solo ahi:
`cargo vendor` copia DESDE `~/.cargo/registry`, que en gioser esta en `/` (74 G)
y no en `/mnt/vvv` (255 G). Hoy eso eran 138 G libres segun el vigia y 19 G de
verdad — luz verde mientras el que se llenaba era el otro, y llenar `/` no
rompe una tanda, rompe la maquina.
Ahora toma el MINIMO de los dos FS (el del repo y el de CARGO_HOME). Si
comparten FS, `df` devuelve lo mismo dos veces y es inocuo.
La purga tambien se queda corta y crece:
- `work/repos/*`, los clones --mirror de gitea de las hub-only. Se re-clonan.
- `$CARGO_HOME/registry` como ULTIMO recurso y CON GUARDA: el act-runner de
tawasuyu corre como el mismo usuario y comparte ese ~/.cargo. Purgarlo con un
`cargo` ajeno en vuelo le arranca los ficheros a SU compilador. Se pregunta
por el codigo de salida de `pgrep`, no por su stdout.
De paso queda dicho en la cabecera que CARGO_HOME se vigila: en gioser el
driver se lanza con el suyo propio dentro del volumen, y asi hammer deja de
gastar `/`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd5DComN9Nu47TBpYMBHBn
La tanda del 2026-08-13 ABORTO con 7 G libres despues de haber purgado a los
25: una sola receta se comio >18 G entre purga y purga. Son los cargo vendor
de las recetas Rust grandes, que sueltan varios GB cada una.
Con 40/15 la purga ocurre ANTES de que una receta gorda agote lo que queda,
en vez de justo despues. El guardian de aborto hizo su trabajo —paro sin
llenar el disco y sin corromper nada— pero llegar a el significa perder la
tanda; el objetivo es no llegar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Con un worker el corpus son ~7 dias (medido: ~9 min/receta sobre 1161).
Repartirlo es la unica forma de bajarlo, y no hace falta planificador
central: cada worker construye SU parte y las deps que le falten se las
construye `hammer build` solo. Al cosechar todo converge en el mismo store
porque las direcciones coinciden — eso lo PRUEBA el paso 4 de
farm-lab-sync.sh, no se supone.
El reparto es por INDICE (i-1, i-1+N, ...) y no por bloques contiguos: el
orden ya esta por objetivo, asi que bloques contiguos le darian a un worker
todo el tramo GUI —el mas lento— y a otro solo hojas rapidas. Intercalando,
todos avanzan por el mismo terreno a la vez.
⚠ Hay trabajo DUPLICADO y es deliberado: dos workers con recetas que cuelgan
de qtbase lo construyen los dos. Sale a cuenta frente a serializar, pero con
4 workers no son 4x, son ~3x.
Un log por shard, o se pisan. Verificado: los 4 shards suman 1161 exactas y
rechaza 5/4 y `abc`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sincronizar el codigo nuevo al worker y llamar a cargo NO basta: rsync -a
preserva el mtime del hub, asi que el fuente recien llegado puede quedar mas
VIEJO que el binario que el worker compilo hace un rato. cargo dice
«Finished in 0.09s», el worker se queda con el hammer anterior, calcula la
huella vieja y el paso 4 falla sin que se vea por que.
Paso el 2026-08-12 al empujar el lab con los -dev: el worker tenia el lab.rs
correcto Y el rootfs correcto, y aun asi divergia. Se arregla con un `touch`
a los fuentes antes de compilar.
El guardian del paso 4 hizo su trabajo: detecto la divergencia y NO dejo
construir. Sin el, el worker habria molido horas sellando en direcciones que
el hub nunca iria a buscar.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sha256 3c9c1ef3... reemplaza a db8a3252... La anterior producia un python3 sin
_ctypes ni _curses y con el se caia la familia GNOME/KDE entera.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decision del usuario. CPython construye sus modulos opcionales como .so
COMPARTIDOS y las .a del corpus NO son PIC («relocation R_X86_64_PC32 against
symbol 'stdscr'»), asi que declararlas en [deps] NO funciona: se probo una
por una. Las libs del rootfs son compartidas y si sirven.
Añadidos: libffi-dev ncurses-dev readline-dev sqlite-dev bzip2-dev xz-dev
expat-dev. Resultado VERIFICADO: el guardian de python3 importa los OCHO
modulos (_ctypes _curses readline sqlite3 bz2 lzma zlib pyexpat) y pasa. De
paso `libffi` sale de [deps]: ya lo aporta el lab.
openssl-dev NO va, deliberado: se de-Alpinizo porque el host-tool del kernel
enlaza el openssl del corpus desde el overlay; devolverlo podria cambiar el
artefacto del kernel. python3 sigue sin `ssl`.
Y SE CIERRA EL AGUJERO QUE ESTO ABRIA. Esas libs del rootfs ahora entran en
el CONTENIDO de un artefacto, asi que su version es parte de su identidad ⇒
van a TOOLCHAIN_PREFIXES. Sin eso habriamos reabierto en pequeño el mismo
fallo que 58d3161 cerro: instalar los -dev subio ncurses 6.5→6.6 y readline
8.3.1→8.3.3 —sin tocar gcc/rust/musl, lo confirmo el lock— y ese salto habria
cambiado el python3 producido SIN mover su direccion.
Coste: la huella del lab cambia ⇒ el corpus se re-hashea otra vez. Se hace
AHORA a proposito, con 194 artefactos, no con el corpus a medio construir.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`du -s hammer/store/*` incluia `.dmerge`, la cache transitoria de fusion del
store: 48 GB en el box el 2026-08-12. Recorrerla hacia que --listar tardara
lo bastante como para cortarse por timeout, y entonces el box "devolvia 0
artefactos" — el guardian de los 0 hizo bien su trabajo y se nego a pisar el
manifiesto, pero la causa no era el enlace.
Con `store/[0-9a-f]*` el listado baja de cortarse a 3 s, y de paso descarta
en el origen `.bootstrap-tmp`/`.mirror-tmp`, que nunca son artefactos. Un
artefacto SIEMPRE empieza por hex: filtrar en el origen sale mas barato que
traerse la basura y descartarla despues.
Verificado: 2037 artefactos, 0 vacios.
⚠ Aparte: que .dmerge ESTE en el respaldo es en si un problema — son 48 GB de
cache reconstruible ocupando el box, y el propio script la excluye al subir
(`--exclude /.dmerge`). Llego por otra via. No se toca aqui: borrar en el
destino es una decision deliberada y aparte.
Y una limitacion del guardian de vacios que conviene conocer: durante una
SUBIDA en curso, los directorios a medio escribir son indistinguibles de los
rotos. Reporto 101 vacios a mitad del rsync y 0 al terminar. No es un falso
positivo del filtro: es que la pregunta no tiene respuesta estable mientras
se escribe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sus deps.build resuelven contra el directorio hermano, que en .deferred no
existe: fallan al instante con «no pude cargar la dep de build 'zlib' de
'git' (recipes/incoming/.deferred/zlib.toml)». Son 25.
Intentarlas no es solo inutil: mete 25 FALLA en el log que parecen roturas
del corpus y esconden las de verdad. Un log lleno de fallos esperados es un
log que nadie lee.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dos fallos que se destaparon montando el primer worker con el lab anclado
(2026-08-11).
1. `--exclude /work` con barra inicial ancla SOLO la entrada `work`, no su
contenido. Con `--include '/work/'` delante, rsync entraba al directorio y
sus hijos no casaban con ninguna regla ⇒ se copiaba work/ ENTERO: 3,7 G,
incluidos los mirrors de git A MEDIO COPIAR porque el propio rsync abortaba
antes de terminarlos. El worker fallaba cada receta con `git fetch exit
128`. Un work/ a medias es peor que ninguno: parece que esta. Se ancla con
`/work/**`.
Eso mismo causaba el abort: con --delete, rsync moria con «cannot delete
non-empty directory» sobre los vendor/ de cargo, que quedan de SOLO
LECTURA. Sin tocar work/, no hay nada que borrar ahi.
2. El `set -e` hacia que ese fallo abortara el script ANTES de armar el
dead-man switch ⇒ server VIVO que no puede autodestruirse, que es
exactamente lo que este script declara inadmisible. Ahora el rsync no es
fatal: avisa y sigue hasta armarlo. El orden ideal es armar el dead-man
ANTES de cualquier paso que pueda fallar; queda anotado en el codigo.
Verificado en seco contra un worker real: el patron nuevo no toca work/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
farm-sync excluye /.dev-fs a proposito (el lab es del entorno, no del repo).
Eso valia cuando el toolchain no entraba en el hash. Desde 58d3161 si entra,
asi que un worker con el lab horneado de la golden (rustc 1.96) sella en
direcciones DISTINTAS a las del hub (1.97): no es que compile distinto, es
que lo guarda donde el hub nunca lo va a buscar.
Tampoco basta bootstrap-devfs.sh en el worker: su paso 0 solo trae la imagen
si NO hay rootfs, y la golden trae uno. Se reemplaza a la fuerza.
La imagen se EMPUJA por scp desde el hub en vez de bajarla del Storage Box,
para no poner la llave del box en el worker: el modelo hub-and-spoke dice que
el worker es compute puro sin secretos.
El paso 4 no es 'extraje la imagen', es comparar el hammer hash de una receta
testigo entre worker y hub. Si divergen FALLA: un worker que sella en otra
direccion quema dinero produciendo artefactos que nadie encuentra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hace falta cada vez que cambie la imagen del lab, porque desde 58d3161 el
toolchain entra en hash_inputs y eso mueve el hash de TODO el corpus. Es el
precio explicito de cambiar de compilador; antes se pagaba sin enterarse.
EN SERIE bajo el flock compartido (ADR 0012): dos builds que compartan una
dep se pisan el arbol de fuentes y queda roto para siempre.
REANUDABLE sin estado propio: pregunta `hammer hash --check` (~2 ms) en vez
de llevar un fichero de progreso. Matarlo y relanzarlo continua donde iba.
POR OBJETIVO, no alfabetico: hay 1186 recetas y solo 168 alcanzan una imagen.
Ordenado por targets.toml primero, cada perfil que cierra es utilizable ya;
por orden alfabetico, tras un dia de maquina no habria ni una imagen.
Dos correcciones en el camino: usar los nodos de build-state marcaba 1161 de
1161 como prioritarias (ese grafo ES el corpus entero, no las clausuras), y
`glob('recipes/**/*.toml')` se dejaba 25 ficheros fuera — os.walk da 1186,
que es lo que cuenta `find`.
La purga de work/sources va ENTRE recetas, con nada corriendo. Purgar con un
build en vuelo le arranca los ficheros al compilador: paso el 2026-08-10 y se
llevo un kernel a medias.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Al entrar el toolchain en hash_inputs (58d3161) se abrio un agujero
operativo: los repos del rootfs apuntan a Alpine edge, que es RODANTE, asi
que dos maquinas que corran bootstrap-devfs.sh en fechas distintas resuelven
toolchains distintos y NO COMPARTEN NI UN ARTEFACTO. Ni cosecha de granja, ni
mirror pull, ni un cache-hit. Cada worker efimero nacia con un lab propio.
`apk add` contra edge es irrepetible por diseno (edge sirve solo la ultima
version). La salida no es repetir la resolucion, es no repetirla: el rootfs
se construye UNA vez y se TRAE pineado por sha256 — el mismo trato que ya
reciben alpine-minirootfs y zig en este script.
imagen: 294 M comprimida (1,1 G extraida)
sha256: db8a32526ad41fbcc76223842dbb4eb65c8ed576570b82152dc7c5691fc2a349
EL TAR ES DETERMINISTA A PROPOSITO (--sort=name --mtime=@1 --owner=0
--numeric-owner): sin eso el sha depende del orden del directorio y del uid
de quien empaqueta, dos empaquetados del MISMO rootfs darian imagenes
distintas y no habria forma de verificar que una imagen es la que dice ser.
Comprobado: dos pasadas, mismo sha.
EL REEMPLAZO ES POR ROTACION, no extrayendo encima: a medias quedaria una
MEZCLA de dos rootfs, que es justo lo que esto existe para evitar.
VERIFICADO de punta a punta: bajada del box, sha intacto tras el viaje,
extraida, y la huella de lab que produce es IDENTICA a la del rootfs vivo
(zlib -> b3:c7a7338f... en las dos). O sea que una maquina que arranque de
esta imagen comparte store.
--from-scratch conserva el camino viejo: es como se FABRICA una imagen nueva.
Reconstruir el corpus es el precio de ese cambio, y ahora es explicito.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alpine no publica snapshots datados de edge y el repo sólo sirve la última
versión, así que ese camino no estaba pendiente: estaba cerrado. Apunta al
lock del paso 3a-quater, que es lo que sí se puede hacer hoy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los repos del rootfs apuntan a Alpine edge (bump deliberado por el techo
MSRV). Es RODANTE: el mismo script en dos fechas da compiladores distintos.
Medido el 2026-08-10 al bootstrapear gioser — el laptop tenia rust 1.96 y
aqui edge resolvio 1.97.0-r0. Para un proyecto cuyo invariante es reproducir
eso es deriva del LAB, y no aparece en build-state.json.
No es un pin y no puede serlo: edge sirve solo la ultima version (`apk policy
rust` lista unicamente 1.97.0-r0), asi que `apk add rust=1.96.0-r0` rompe en
cuanto edge avanza, y Alpine no publica snapshots datados de edge. Anclar de
verdad pide espejar APKINDEX + los .apk, que es un frente aparte.
Lo que si se puede hoy: registrar los 92 paquetes resueltos en
docs/state/lab-toolchain.lock —que viaja por git, que es como los dos hubs lo
comparan— y AVISAR con el diff delante cuando la maquina difiere. Aceptar el
cambio es deliberado: --relock. Un lock que no se puede imponer sigue
valiendo si al menos nombra lo que cambio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dos eslabones de la misma cadena, que el 2026-08-10 dejo cuatro recetas con
OK sin producir un solo fichero.
Store::has era path_of(..).is_dir(): un directorio vacio contaba como
sellado, asi que build() hacia cache-hit y devolvia Ok sin construir. Ahora
exige al menos una entrada. NO exige el sidecar .hammer/recipe.toml aunque
seria mas expresivo: ese lo escriben los llamantes, no seal(), y
hammer-bootstrap sella sin el ⇒ pedirlo lo haria reconstruir siempre. Va con
test de regresion.
--listar armaba el manifiesto con `ls`, que lista NOMBRES: un vacio es
identico a uno bueno, y de ahi build-state.py lo daba por sellado. Ahora usa
`du -s` (8,6 s sobre 1751, frente a un ls instantaneo), separa los vacios a
work/respaldo-vacios.txt y los DICE siempre, tambien cuando son 0.
Cuidado con el orden en ese awk: recortar la ruta antes se come el contador
de bloques y el filtro compara el nombre en vez del tamano — daba 406 vacios
falsos. Primero filtrar por numero, despues recortar.
Quedan 3 vacios sin curar en el respaldo (dbus x2 y un libxkbcommon de hash
superado); no caen en ninguna clausura construida.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Estaba fijo en ssh://…git.tawasuyu.net:2345/… mientras el clon de gioser
fetchea de gitea@git.gioser.net:… — la misma máquina, pero otra forma de URL
y otro puerto. Correr el script tal cual hacía --unset-all y le cambiaba el
destino canónico al clon sin avisar: un push que se va a donde no era y no
se nota hasta que importa.
Ahora deriva del origin de cada clon y el hardcodeado queda de fallback,
así sirve igual en el laptop y en gioser. Sigue siendo idempotente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sin TTY, --info=progress2 reescribe con \r y el progreso se acumula en
miles de copias de la misma línea. El respaldo corre DESATENDIDO, así que
ese log es la única forma de saber qué pasó; a 2,6 MB no se lee.
Mismo defecto y mismo arreglo que en worker-depositar.sh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Montando gioser como segundo hub salió el defecto: `sealed_remoto` cuenta
cuántos sellados NO están en el disco de QUIEN calcula. El laptop tiene 219
artefactos y gioser 19, así que el mismo corpus da 700 y 751. Metido en un
fichero commiteado, las dos máquinas se lo pisarían en cada regeneración,
para siempre.
El estado del corpus es compartido; cuánto de él tiene esta máquina en el
disco, no. Se sigue diciendo en el resumen, donde es útil y no genera churn.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1. build-farm.sh — vulkan-loader fallaba con «io: File name too long (os
error 36)» en TODOS los ciclos y sella sin una queja ejecutado en serie
(comprobado a mano en el worker). La firma no estaba en la lista de
colisiones del reintento serial, así que nunca se reintentaba: fallaba,
contaba como deuda, y al ciclo siguiente fallaba igual.
Un fallo que se repite IDÉNTICO no es intermitente: es uno que nadie
está reintentando.
Se añade la firma, pero el defecto de fondo era que faltarla fuese MUDO
— la lista sólo puede crecer si alguien se entera de que se quedó corta.
Ahora lo no reintentado se dice, con las primeras 120 letras del log.
2. farm-up.sh — no sembraba work/farm-sellados.txt. Un worker recién
creado arrancaba con el manifiesto rancio horneado en la golden: 1172
contra 1241 del hub, así que reconstruía lo que el hub ya tenía sellado
(vulkan-loader entre ellos, y encima fallando).
Es el mismo error de método que farm-sync.sh ya se documentó a sí mismo:
«lo puse allí, di el bucle por cerrado, y la churn siguió porque hay DOS
rutas». Había dos otra vez y sólo una estaba arreglada.
Tras el arreglo las cuatro colas dan 1/1 construyen (0 fallan) y el perfil
escritorio-kde cierra 162/162.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cadena completa medida hoy: el worker depositó 63 artefactos (1,34 GB) en
8 s a 153 MB/s, y el hub los promovió con mv. Respaldo 1542 → 1605, buzón
en 0, guardián cuadrando. Contra los 440 kB/s del laptop, 348×.
Dos fallos de la prueba, que importan más que el resultado:
1. `ssh` cortaba con «Host key verification failed» porque la granja REUSA
IPs y la host key cambia con razón. Como el stderr iba a /dev/null, el
error salía como «no obtuve la pública del worker»: culpaba al worker
cuando el problema estaba en el known_hosts del laptop. Ahora va por
ssh_worker(), que purga la entrada vieja — correcto sólo acá, porque la
identidad del worker es su label hcloud, no su llave.
2. verificar-buzon daba «aislado ✓» sin haber probado que escribe. El
aviso de host key se comía el head -1, y el testigo se llamaba .btest,
que `ls` no muestra. Un testigo invisible no prueba nada. Ahora exige
las dos mitades y las dos están verdes: escribe en su buzón, y
../hammer/store no existe para él.
Y el progreso de rsync sólo con TTY: sin terminal reescribe con \r y deja
miles de copias de la misma línea. 8 segundos bastaron para un log
ilegible; en el latido sería cada media hora.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los permisos de subcuenta del Storage Box son BINARIOS: --readonly sí o no.
No hay append-only ni write-sin-delete (verificado en la API, no recordado).
Así que la contención se hace por alcance: el worker escribe en un BUZÓN
cuyo home es incoming/, y hammer/store sencillamente no existe para él. Lo
máximo que puede destruir es lo que él mismo depositó y aún no se promovió,
que sigue estando en el volumen. Un permiso puede estar mal puesto; un
directorio fuera de tu home, no.
La promoción es un mv DENTRO del mismo filesystem: un rename, instantáneo,
cero bytes por la red. Eso es lo que la hace viable con un uplink de
440 kB/s — el laptop manda órdenes, los datos van worker→box por dentro de
Hetzner (122 MB/s, 280×).
Medido de la shell del box, que NO es un bash:
· no hay `for` ⇒ los lotes se arman en el hub
· `a; b` no encadena de fiar ⇒ una orden por conexión
· `mv a b c dest/` sí acepta varios orígenes ⇒ cientos por conexión
· `find` no existe y devuelve 0 EN SILENCIO ⇒ parece «no hay respaldo»
Y la trampa que motivó partir el trabajo en dos conjuntos: `mv A dest/` con
dest/A ya existente NO falla, mete A DENTRO y deja dest/A/A. Como el store
es CAS, un artefacto ya respaldado es idéntico ⇒ no se mueve, se borra del
buzón. Los dos caminos probados de punta a punta con un artefacto falso,
recuento verificado y sin anidar.
Defensa en profundidad aparte: plan de snapshots diario (03:17 UTC, retiene
7 de 10) y la carpeta ZFS visible para recuperar ficheros sueltos sin
rollback. La subcuenta no las alcanza: viven en la cuenta, con el token que
el worker no tiene.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El store de trabajo se mudó al volumen de la granja y el laptop quedó con
219 artefactos: los de fuente privada (que la granja no puede rehacer) más
lo que todavía no está respaldado. 979 artefactos verificados en el box se
borraron de acá, y con ellos el caché .dmerge de 92 G que sostenía sus
bloques: 128 G → 8,2 G, 115 G libres.
Eso rompía tres cosas que leían el disco local como si fuera la verdad:
- build-state.py habría reportado `never` sobre ~950 sellados y el latido
lo habría COMMITEADO. Un grafo recién escrito miente con más autoridad
que uno viejo. Ahora resuelve la presencia contra la unión del store y
los manifiestos, y expone `sealed_remoto` para que «el store se mudó»
no se lea nunca como «el corpus creció».
- farm-sync.sh armaba el manifiesto con `ls ./store`, así que le habría
dicho al worker «el hub tiene 220» y el worker habría rehecho ~950. Es
el bucle de churn que el propio fichero documenta, al revés. Ahora es la
unión, con un guardián que aborta si el manifiesto encoge.
- cosecha-cron.sh bajaba el store entero cada 30 min: habría deshecho la
mudanza sola, como la poda de 24 G que se deshacía en 2026-08-07. Ahora
baja sólo la lista de nombres.
Los cuatro grafos regenerados dan idéntico a antes del recorte
(766/11/2), que es la prueba de que no se perdió nada.
Queda abierto: los artefactos nuevos viven SÓLO en el volumen hasta que
alguien corra una pasada de respaldo. El volumen tiene borrado protegido,
pero es una copia.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dos cosas medidas al traer los 167 GB.
1. `rsync -a` SIN `-H` no preserva los enlaces duros, y el store comparte ficheros entre artefactos
justamente así. Llegó inflado: 76 G en el box → 159 G en el volumen, con **0 ficheros de nlink>1**
al llegar — ésa es la prueba de que se perdieron, no una sospecha. (El 76 G del box es además ZFS
comprimido, así que las dos cosas se sumaban y parecía peor.) El contenido es correcto —es CAS y
los hashes casan—, pero ocupa de más y el volumen no sobra.
2. El respaldo conserva artefactos que el hub YA podó. Son SUPERADOS: existe otro con el hash
vigente, y no pueden dar cache-hit nunca porque la receta que los nombraba cambió. Eran 544 de
1536 · 31 G. Podados con work/store-gc-superados.txt como lista.
El guardián es RECONTAR tras el rm: «borré 544» y «hay 544 menos» son afirmaciones distintas, y
sólo la segunda es la que importa. Cuadró.
Volumen: 992 artefactos vigentes, 95 G libres de 246.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MEDIDO, no supuesto: el uplink de la oficina da 440 kB/s ⇒ los 127 G del store son 82 horas. Subir
el store desde el laptop no es lento, es imposible. Pero el Storage Box está en hel1 y el worker
está en hel1: por la red interna la misma copia va a 122 MB/s. Son 280×.
⇒ El laptop deja de ser el camino de los datos. Sólo manda las RECETAS (26 M).
POR QUÉ SUBCUENTA Y NO LA CUENTA PRINCIPAL. El worker es efímero y se borra solo; darle la
credencial principal sería darle permiso de borrado sobre el ÚNICO respaldo que existe. Un respaldo
al que puede escribir la máquina de la que hay que protegerse no es un respaldo. La subcuenta acota
el daño a cero por construcción: --readonly, --reachable-externally=false, home acotado a hammer/.
LA VERIFICACIÓN ES NEGATIVA. «Sólo lectura» es una afirmación sobre lo que el sistema IMPIDE, así
que leer no la prueba. Hay que intentar escribir y borrar y exigir que fallen:
rm → «Read-only file system» · scp → «dest open: Failure» · respaldo intacto.
El script trae ese paso como subcomando `verificar` y sale ≠0 si la subcuenta resulta escribir.
Dos gotchas que costaron:
· la API exige contraseña con mayúscula+minúscula+número+símbolo aunque después se use llave;
· la shell del Storage Box es RESTRINGIDA: acepta ls/mkdir/rm pero NO redirección, así que
`echo k > authorized_keys` falla EN SILENCIO (crea el directorio, no el fichero). Va con scp.
La llave se genera EN EL WORKER (nunca viaja una privada desde el laptop) y muere con él.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El bloque del volumen es una cadena entrecomillada que se manda al worker, así que un backtick ahí
no es tipografía: es SUSTITUCIÓN DE ÓRDENES. Mis comentarios de la commit anterior citaban
`volume attach` y `blkid` con backticks ⇒ la shell intentó EJECUTAR «volume attach» («volume:
command not found») y a partir de ahí el resto del bloque se mandó mutilado: mkfs.ext4 sin
dispositivo, mountpoint sin argumento.
Por eso los comentarios originales de ese mismo bloque escapan los backticks con \`. Yo escribí los
míos con el estilo del resto del fichero, que es correcto FUERA de la cadena y venenoso dentro.
Arreglado quitándolos: en un comentario que viaja por SSH, la comilla no vale lo que cuesta.
El volumen no sufrió: sigue con su filesystem del 17 de julio y montado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Poblar el volumen con el store del hub son horas de rsync sin un solo `hammer build`. El dead-man
sólo miraba builds y latido ⇒ acumulaba ticks y borraba el worker A MITAD DE LA COPIA. El volumen
sobrevive (para eso está), pero la transferencia muere y hay que reanudarla a mano; sobre un enlace
lento eso puede no converger nunca.
`pgrep -x` y no `pgrep -f`: `-f` mira la línea de órdenes entera y se auto-matchea con el propio
dead-man si su ruta contiene la cadena. Ese error ya nos hizo informar cuatro veces procesos «vivos»
que estaban muertos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Investigando por qué el worker nuevo arrancó con 2 artefactos en vez de ~900, apareció esto.
BOMBA 1 — `rm -rf $REMOTE/store` sin comprobar si es punto de montaje.
La guarda era `[ -d ] && [ ! -L ]`: basta en un server creado desde la golden ORIGINAL, donde
/opt/hammer/store es un directorio normal. Pero al re-snapshotear un worker (ayer, para meterle
Rust y Go) la imagen se llevó también LA CONFIGURACIÓN DE MONTAJE ⇒ el server nuevo monta el
volumen en esa ruta AL ARRANCAR, antes de que corra el «rescate». Entonces el `rm -rf` borra A
TRAVÉS DEL MONTAJE y se lleva el store del volumen entero — justo lo que el volumen existe para
proteger.
Evidencia: el volumen pasó de ~900 artefactos a 2, pero NO se perdió el filesystem (lost+found
de julio, 17 montajes, .dmerge intacto). Se borró el CONTENIDO, no el disco. Ahora hay
`mountpoint -q`: si ya está montado no hay nada que rescatar ni que borrar.
BOMBA 2 — `mount … || { mkfs.ext4 -F …; }`. Formatear ante CUALQUIER fallo de montaje, incluida
la carrera normal entre `volume attach` y la aparición del dispositivo. Un hipo de segundos
destruía el volumen. Ahora sólo formatea si `blkid` dice que NO tiene filesystem; si lo tiene y
no montó, aborta y lo dice.
Las dos comparten la misma forma: un fallback destructivo que asume la causa benigna. `mv -n`
está bien elegido (no pisa), pero el `rm -rf` que lo sigue anulaba esa prudencia.
Y una consecuencia que hay que asumir: re-snapshotear un worker NO conserva el store — los
artefactos viven en el volumen, que no entra en la imagen. La golden aporta toolchain y sistema;
la caché la aporta el volumen. Hoy la imagen decía traer store y no lo trae.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Imagen 417847948 («hammer-golden-rust-go-2026-08-08») reemplaza a 408909310 como default de
farm-up.
EL PROBLEMA QUE CIERRA: hammer invoca `cargo vendor` y el toolchain Go en el HOST, no dentro
del sandbox — el vendoreo pasa ANTES de entrar a la caja. La golden vieja no los traía, así que
la granja no podía construir NINGUNA receta Rust ni Go: COSMIC entero, las 228 Rust y las 362
Go del corpus. El 76% del catálogo dependía de UNA SOLA máquina, el hub — justo lo que el
respaldo de ayer intentaba dejar de asumir.
VERIFICADO ANTES DE SNAPSHOTEAR, no después: `cosmic-bg` —que minutos antes moría con
`spawn cargo vendor: No such file`— sella en el worker. Un snapshot de una máquina a medio
arreglar es peor que ninguno.
MISMAS VERSIONES QUE EL HUB (cargo/rustc 1.96.0, go 1.26.4) a propósito: introducir un skew de
toolchain nuevo mientras se arregla otro es cómo se fabrican los fallos que nadie reproduce. Y
1.96 es además el techo MSRV del sandbox ya documentado.
NO RELAJA LA HERMETICIDAD: cargo/go son herramientas de FETCH, del mismo orden que `git` para
clonar. El build sigue ocurriendo dentro del sandbox con el árbol ya vendoreado. Es lo contrario
del caso «no engordar el rootfs del worker», que habla del rootfs DEL SANDBOX.
El PATH queda persistido en /etc/profile.d/hammer-toolchain.sh para que sobreviva a los logins
no interactivos de la granja. Y la imagen trae el store del worker al momento del snapshot (KDE
y GNOME reconstruidos hoy), así que los workers nuevos arrancan con más caché.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>