Con los siete de `dependencies.js` cerrados, el arranque avanzó y murió en uno que NO
está en esa lista:
JS ERROR: Requiring GIRepository, version 2.0: Typelib file ... not found
El que lo pide es **gjs**, desde su propio JavaScript embebido: su GResource trae
literalmente `imports.gi.versions.GIRepository = '2.0';` (verificado con strings sobre
libgjs.so.0). Dep de runtime del INTÉRPRETE — invisible al grafo de build Y a la lista
del shell. O sea que `dependencies.js` es necesario pero no suficiente: hay una capa
más abajo.
HAY DOS GIRepository Y NO SON LO MISMO, y los números confunden a propósito:
· GIRepository-3.0 = la API NUEVA, la que GLib absorbió (libgirepository-2.0). La
produce glib-introspected y ya estaba.
· GIRepository-2.0 = la API VIEJA, la de libgirepository-1.0.so.1, contra la que gjs
está enlazado. La produce gobject-introspection, pero sólo con
-Dbuild_introspection_data=true, y el nuestro va con false.
La 2.0 es la vieja porque nombra la librería 1.0; la 3.0 es la nueva porque nombra la 2.0.
Receta aparte en vez de prenderle la opción a g-i: su radio alcanza toda la cadena GNOME
(catorce recetas la declaran para escanear) y además traería de vuelta los typelibs de
X11/cairo que motivaron apagarla. Acá el radio es cero.
Replica el custom_target('gir-girepository') de upstream (gir/meson.build:494) pero
escaneando contra la libgirepository-1.0 ya instalada. **La lista de fuentes va enumerada
a mano y no como glob**, y no es prolijidad: `girepository/*.h` barre también
`gitypelib-internal.h`, que declara G_TYPELIB_ERROR; el scanner emite entonces el stanza
del error-quark y su binario temporal no linkea, porque `g_typelib_error_quark` es LOCAL
en la .so instalada. Upstream nunca lo escanea — el glob era el error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rsvg-2.0 librsvg b3:351f4658
IBus-1.0 ibus b3:d4d3750a
**1. PERILLA NUEVA DEL LAB: `[source] cargo_vendor`.** `detect_build_system` asume UN
sistema de build por árbol, y librsvg genuinamente tiene dos: `configure.ac` gana por
precedencia pero su Makefile llama a `cargo build`, que dentro del sandbox hermético
no tiene red. La perilla fuerza el vendoreo (que ocurre en el fetch, donde sí hay red)
sin tocar la detección. **No entra al ArtifactHash** —decide de dónde salen las deps,
no cuáles: eso lo fija el Cargo.lock, ya bajo el sha256 de la fuente— así que se puede
prender en una receta ya sellada sin re-hashear nada. Con test, y verificado en vivo:
libelogind no movió su hash tras el cambio.
**2. LA DEUDA DEL UNWINDER, PAGADA.** librsvg moría en
`undefined reference: _Unwind_DeleteException`. No es de librsvg: es de la `std` de
rustc, que trae landing pads y espera el runtime que en glibc vive en libgcc_s. Es la
deuda que el corpus arrastra desde matar-gcc —«las 12 recetas Rust son UN problema, no
12»— y el remedio estaba a mano: **zig empaqueta la libunwind de LLVM** y exporta los
`_Unwind_*` (verificado con nm). Alcanza `LIBS=-lunwind`. Va por LIBS y no por LDFLAGS
porque autotools pone LIBS al FINAL de la línea de enlace, que es donde tiene que ir
una librería que resuelve símbolos indefinidos.
librsvg va en 2.58.5 y no 2.59+: en 2.59 cambió a meson + cargo-c, y cargo-cbuild
enlaza el crate `cargo` entero (libgit2, libssh2, libcurl, openssl) — una campaña
propia por un binario que sólo corre en el constructor. 2.58.5 produce el mismo
Rsvg-2.0. Mismo criterio que gnome-desktop 44.5.
**3. `x11-compose-data`, y es una tensión que vale la pena tener escrita.** ibus COMPILA
la tabla de teclas muertas de X11 dentro de libibus (Makefile.am:310, incondicional, sin
`--disable-`). Sin datos no construye. Los datos viven en el tarball de libX11 por
historia, no por necesidad técnica: son 5192 líneas de reglas. La receta extrae SÓLO los
ficheros de locale reproduciendo la regla de upstream (`cpprules.in`: cpp crudo +
CPP_SED_MAGIC literal) y no compila una línea de X11. Un escritorio Wayland-only sigue
necesitando la tabla de composición del mundo Unix.
Gotchas de ibus, los tres medidos: su ayuda MIENTE (`--enable-gtk4`/`--enable-wayland`
sugieren default apagado; el default es `yes`); `--disable-emoji-dict` NO apaga
`--disable-unicode-dict`; y con `--disable-wayland` el build muere igual porque
`tools/main.c` llama wl_display_* sin `#ifdef` mientras WAYLAND_LIBS sólo se agrega si
la opción está prendida — bug de upstream en su propia configuración sin Wayland.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dos rondas del mismo patrón, las dos destapadas ARRANCANDO:
1. `Requiring Gdk 4.0: Typelib 'cairo' 1.0 not found` ⇒ cairo-1.0 sumado a
gi-foreign-typelibs. Necesita el mismo sed que gi-foreign-girs (viene como .gir.in
con dos placeholders), para compilar EXACTAMENTE el XML que está instalado: si
divergieran, el typelib describiría otra librería. NO segfaultea g-ir-compiler — el
crash que motivó -Dbuild_introspection_data=false era del g-i de antes de la isla
dinámica. La lista de cinco no se adivinó: sale de leer los <include> de TODOS los
.gir del cierre y cruzarlos contra los typelibs existentes.
2. `Requiring Gdm 1.0` ⇒ receta `libgdm`, SÓLO la librería cliente. gnome-shell la
importa sin condicional (js/misc/dependencies.js:12), o sea que **la librería
cliente de GDM es dep de runtime del SHELL, no del greeter** — y eso no se ve en
ningún meson.build. `gdm.toml` (el demonio entero) sigue aparcada por linux-pam,
pero PAM es del DEMONIO: libgdm/ no lo toca. La receta corta por ahí; cuando exista
linux-pam las dos conviven.
Para que libgdm compilara hicieron falta tres símbolos más en libelogind
(tawasuyu 9bf977a52, re-pineado a 98db584fd, re-sellado b3:c1fc4bd8):
sd_seat_get_sessions, sd_session_get_service y **sd_booted**, éste en una cabecera
nueva systemd/sd-daemon.h. sd_booted NO es un stub que devuelve 0: comprueba lo mismo
que systemd —que exista /run/systemd/system/— y bajo arje no está, así que responde 0
porque ES 0.
gnome-shell re-sellado b3:b2d5919b por la cascada.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Con accountsservice adentro, la capa JS avanzó y murió un paso después:
JS ERROR: Requiring Atspi, version 2.0: Typelib file for namespace 'DBus', version '1.0' not found
`Atspi-2.0.gir` declara `<include name="DBus" version="1.0"/>`. El `DBus-1.0.gir` YA estaba en
/usr/share/gir-1.0 (lo pone gi-foreign-girs), pero gjs resuelve en runtime contra el TYPELIB
compilado, no contra el XML — el XML le alcanza al scanner, no al cargador.
Receta aparte y no un compile agregado a gi-foreign-girs porque `yupana radio` da **17 sellados
cayendo a deuda** ahí (catorce recetas la declaran para escanear, mutter y gnome-shell entre ellas).
Compilar cuatro typelibs no justifica reconstruir la cima. No se pisan: una instala sólo en gir-1.0
y la otra sólo en girepository-1.0. Mismo criterio que separó udev-pc de libudev-zero.
Se compilan CUATRO nombrados explícitamente —DBus-1.0, DBusGLib-1.0, fontconfig-2.0, freetype2-2.0—
y no un glob: los .gir de X11 y cairo arrastran includes que no tenemos y son justamente los que
hacían SIGSEGV a g-ir-compiler (la razón de nuestro -Dbuild_introspection_data=false).
Además: gnome-start lanza accounts-daemon explícito. Su .service de activación está instalado, pero
la activación por bus de sistema pasa por dbus-daemon-launch-helper, que acá no es setuid root;
correr como root probablemente alcanzaría, pero depender de eso es depender de un accidente.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los dos huecos que bloqueaban esta receta estaban medidos desde ayer símbolo por
símbolo, y ninguno era de accountsservice. Los dos cerrados:
1. libelogind pasa de 14 a 22 símbolos (tawasuyu 8d892151b, re-pineado a b56aeff46,
re-sellado b3:9650ee06). Los ocho nuevos son los que usa accountsservice, con
`sd_login_monitor_*` implementado sobre inotify en /run/systemd/{sessions,seats,users}.
2. `fgetspent_r` no existe en musl ⇒ accountsservice-fgetspent_r-musl.patch. NO es
sustituir por `fgetspent()` a secas: daemon.c guarda los buffers en un GHashTable
y `fgetspent()` devuelve un struct estático que se reescribe en cada llamada —
todas las entradas de la tabla apuntarían al último usuario leído. El parche copia
el registro al buffer del llamador, y sigue el patrón que el propio accountsservice
ya usa para /etc/passwd (`src/fgetpwent.c` bajo `#ifndef HAVE_FGETPWENT`).
Produce `AccountsService-1.0.typelib`, que es exactamente lo que la capa JS pedía.
De paso, dos cosas que este frente enseñó y quedan horneadas:
- libelogind necesitaba `cargo_vendor_dir` (el `vendor/` de smithay entró a la rama
selfhost con el merge de main; el mismo choque que ya tenía arje-logind-compat).
- `hydrate-gnome.sh` ahora hidrata DOS raíces por defecto. accountsservice no es dep
de build de nadie: es dep de RUNTIME, resuelta por gjs al arrancar. **El cierre de
build no es el cierre de runtime**, y lo que el shell carga por `imports.gi.*` hay
que nombrarlo a mano o no entra al rootfs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tres cambios de diagnóstico en `gnome-start` que, juntos, dieron el veredicto:
1. SONDA DE INPUT antes de lanzar el shell. `libinput list-devices` hace lo mismo
que `init_libinput()` de mutter (udev_new + create_context + assign_seat) pero
abriendo el devnode por su cuenta. Volvió con rc=0 y los tres dispositivos
listados ⇒ **la hipótesis libudev-zero del runbook queda REFUTADA**: la
enumeración por /sys funciona.
2. El volcado POR HILO corría DESPUÉS del `kill -ABRT`, o sea sobre un
/proc/<pid>/task que ya no existía: salía vacío. Movido antes. Ahí apareció el
dato: `tid 201 [Mutter Input Th] wchan=evdev_read syscall=0`. El hilo de input
dormido en un `read()` de evdev dentro del kernel. (El core no servía: gdb no
desenrolla a través de musl y devuelve `?? ()` para los 11 hilos no principales.)
3. `MUTTER_DEBUG` es una LISTA DE TÓPICOS (g_parse_debug_string sobre
meta_debug_keys), no un booleano: con `1` no encendía nada. Ahora
`backend,input,kms`, y el tópico `backend` imprime la última línea antes del
cuelgue: «Opening and taking control of device file '/dev/input/event0'».
Más: ping D-Bus a login1 en el diagnóstico (el daemon respondía: no estaba
tildado) y copia de los logs de /tmp —que es tmpfs— a la raíz ext4 para poder
sacarlos con debugfs junto al core.
La causa está arreglada en tawasuyu (3dd88f582): `TakeDevice` abría sin
`O_NONBLOCK`. Receta re-pineada a 2445fa31c y re-sellada b3:7ffa9256.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los dos arreglos de arje-logind-compat (sesión eager + object path de Session
escapado como systemd) están commiteados y pusheados en tawasuyu, así que la
imagen ya NO corre un binario compilado a mano: corre el artefacto sellado, y se
verificó que da EXACTAMENTE el mismo resultado (mismo `Added virtual monitor
Meta-0` → `Using Wayland display name 'wayland-0'` → mismo muro en el typelib de
AccountsService).
El pin va a la rama selfhost, no a main: esa rama existe para que hammer pueda
construir con --locked de forma hermética (el monorepo gitignora el Cargo.lock).
Estaba MUY atrás — el artefacto viejo ni siquiera tenía el objeto Session que
mutter necesita —, así que se le mergeó main y se regeneró el Cargo.lock, que con
main al día había quedado viejo y habría hecho fallar el --locked.
Y ahí apareció una colisión ya conocida pero no aplicada acá: el monorepo COMMITEA
su propio `vendor/` (`[patch.crates-io] smithay = { path = "vendor/smithay" }`, la
copia parcheada que mirada usa para el tearing) y `cargo vendor` de hammer escribe
en `vendor/` por defecto, pisándolo. El build moría con
`failed to read /src/vendor/smithay/.cargo-checksum.json`. Se resuelve con el
campo que ya existía para esto: cargo_vendor_dir = ".hammer-cargo-vendor".
De paso, el script de imagen elige el artefacto por `hammer hash` sobre la receta
—el que corresponde a la receta de HOY— en vez de por el más reciente del store:
con dos artefactos del mismo paquete conviviendo, la fecha no dice cuál es el
vigente. Mismo criterio que hydrate-gnome.sh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Experimento decisivo: `gnome-shell --headless --virtual-monitor 1280x800`. El
backend headless crea el seat con META_SEAT_NATIVE_FLAG_NO_LIBINPUT, o sea que
saltea init_libinput() — justo el último paso del hilo de input antes de señalar
`input_thread_initialized` (meta-seat-impl.c:3098). Resultado:
libmutter-Message: Added virtual monitor Meta-0
libmutter-Message: Using Wayland display name 'wayland-0'
== compositor OK (wayland-0) — el shell ES el display server
⇒ **CONFIRMADO: el bloqueo del camino DRM está en libinput/udev, no en el resto
del arranque.** Todo lo demás de mutter funciona. Primer sospechoso libudev-zero.
Y con el compositor arriba aparece el muro siguiente, que es de otra naturaleza:
Gjs-CRITICAL: JS ERROR: Requiring AccountsService, version 1.0:
Typelib file for namespace 'AccountsService' not found
LA LECCIÓN DE MÉTODO: gnome-shell selló con su cierre de build COMPLETO (108/108)
y aun así la sesión muere pidiendo este typelib. **El cierre de build no es el
cierre de runtime**: todo lo que el shell carga por `imports.gi.*` desde
JavaScript es invisible al grafo de deps. Se encuentra ARRANCANDO, no compilando.
Se autoró recipes/incoming-gnome/accountsservice.toml. El configure pasa entero
—tres seds verificados contra el build real: generate-version.sh (deriva la
versión del nombre del DIRECTORIO, que en el sandbox es /src, y la rama git usa
`date`, o sea no-determinista), la aserción de wtmp (musl no define WTMPX_FILENAME
ni _PATH_WTMPX) y subdir('tests') (arrastra mocklibc, que llama fgetgrent, ausente
en musl)—. Ojo: -Dsystemdsystemunitdir tiene que ser literalmente `no`; con la
cadena vacía el meson interpreta "averiguá el directorio" y aserta pidiendo
systemd.pc.
NO SELLA todavía, y la frontera está contada símbolo por símbolo, no estimada:
· libelogind exporta 14 símbolos (los que pedía mutter) y accountsservice usa
OCHO que faltan: sd_get_sessions, sd_seat_can_multi_session,
sd_session_get_display y los cinco de sd_login_monitor_*. Estos últimos son
la parte con enjundia: no son getters sino una API de NOTIFICACIÓN (un fd
poll-able). Sobre el diseño actual el camino natural es inotify sobre
/run/systemd/{sessions,seats,users}.
· fgetspent_r no existe en musl (extensión glibc de /etc/shadow, usada en
src/daemon.c:265): necesita shim o parche a la variante no-reentrante.
Ninguno de los dos es de accountsservice: son huecos de NUESTRA capa de compat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dos bugs REALES de arje-logind-compat, encontrados arrancando la imagen y
arreglados (con test) en el árbol de tawasuyu — todavía LOCALES, sin pushear:
1. La sesión se creaba PEREZOSAMENTE. `ensure_user` sólo corría dentro de un
método del Manager, y `write_login_state` vive adentro. Pero mutter no
empieza por D-Bus: lo primero que hace es sd_pid_get_session(), que
arje-sdlogin-compat resuelve LEYENDO /run/systemd/sessions. Huevo y gallina:
el estado en disco sólo se escribía después de un pedido que sólo ocurre si
el estado ya existe. Ahora se crea al arrancar (eager_session).
2. El object path de la Session estaba MAL ESCAPADO: era `/session/_1`. La
convención de systemd (bus_label_escape) codifica `_<hex>` todo lo que no sea
[A-Za-z0-9] **y también el primer carácter si es dígito** ⇒ el id "1" da
`_31`. Importa porque el cliente calcula el path por su cuenta y NO pregunta:
mutter reimplementa la misma regla en meta-dbus-utils.c. Con `_1` no había
nadie sirviendo ahí, la propiedad `Seat` volvía NULL y mutter —que no
chequea— moría de SIGSEGV en get_seat_proxy. Los objetos User NO usan este
escapado (systemd hardcodea `_<uid>`), así que user_path() queda igual.
Y ARJE_LOGIN_STATE=1 YA EXISTÍA: el comentario del daemon dice literalmente "en
arje (sin systemd) el launcher de sesión lo prende. Default off" — y el launcher
es gnome-start. El puente que escribía /run/systemd/ a mano era reinventar esa
perilla; queda de fallback inerte.
Resultado: el shell ya no crashea. Corre con 12 hilos, /dev/dri/card0 abierto
tres veces y /dev/input/event0 abierto — el TakeDevice de logind funciona y el
compositor tiene DRM master e input. Queda bloqueado en
meta_seat_impl_initable_init (meta-seat-impl.c:3154): espera en un condvar a que
la "Mutter Input Thread" avise que inicializó, y nunca avisa. Hipótesis principal
libudev-zero, que ya dio un episodio idéntico en el frente de la USB nvidia.
El gnome-start ahora le fuerza un core con SIGABRT al proceso colgado: sin gdb en
la imagen es la única forma de ver dónde está parado.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gnome-shell bootea, compila sus esquemas, toma el DRM de virtio-gpu y se declara
Wayland display server. Después SIGSEGV. El backtrace del core no deja dudas:
#4 g_variant_get (value=0x0, "(s&o)") ← GVariant NULO
#5 get_seat_proxy src/backends/meta-launcher.c:407
#6 meta_launcher_new (META_LAUNCHER_FLAG_TAKE_CONTROL)
Mutter pide la propiedad `Seat` del objeto **Session** de logind — `(s&o)` es su
firma estándar (nombre_del_seat, object_path). arje-logind-compat adquiere
org.freedesktop.login1 y sirve el Manager, pero NO expone un objeto Session con
esa propiedad ⇒ la lectura devuelve NULL y mutter desreferencia sin chequear. Que
mutter no valide es fragilidad suya; el hueco es nuestro.
Cinco eslabones hubo que armar antes de llegar a ese muro, ninguno anotado:
1. gschemas.compiled NO se genera con DESTDIR seteado (meson lo dice en el log
del build) ⇒ GSettings abortaba en el primer g_settings_new().
2. GI_TYPELIB_PATH tiene que incluir /usr/lib/gnome-shell: St/Shell/Gvc/Shew se
instalan aparte por ser privados del shell. Es el env sin análogo en KDE.
3. /var/run no existía en la base metal; arje-logind-compat busca el bus en la
ruta legacy y sin el symlink se iba a "modo idle".
4. La política D-Bus de login1 faltaba (system.conf trae <deny own="*"/> y
normalmente la instala systemd). PERTENECE al artefacto de arje: está en el
script de imagen sólo para dejar visible qué falta empaquetar.
5. /run/systemd/{sessions,seats,users} vacíos — libelogind resuelve la C-ABI
sd-login LEYÉNDOLOS y nadie los escribe (el Announce de arje-logind-compat al
bus del fractal falla con "identity mismatch"). gnome-start los escribe A
MANO y está MARCADO COMO ANDAMIO. Con ese puente desaparece el "Failed to
find any matching session".
Gotcha que costó una iteración: `kill -0` TIENE ÉXITO sobre un zombi (el padre no
lo cosechó todavía), así que el script reportaba "sin wayland-0 tras 45s" cuando
el shell había muerto en el primer segundo. Mirando State: de /proc/<pid>/status
sale el código real, 139.
Todo el ciclo (hidratar → imagen → bootear → sacar el core con debugfs → gdb) y
la lista de lo que falta quedan en docs/runbooks/gnome-qemu-desktop.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proyecta al FHS el cierre de una receta GNOME desde artefactos SELLADOS, sin
rebuild. Resultado sobre gnome-shell: **108 recetas, 0 faltantes** — 614
binarios, 455 .so, 49 typelibs.
Diferencia con scripts/kde/hydrate-from-store.sh, y por qué importa: aquél
resuelve el cierre desde un index.json de repo y elige el artefacto por MTIME con
un CUTOFF. Eso es una heurística — si dos artefactos del mismo paquete conviven
en el store, la fecha no dice cuál corresponde a la receta VIGENTE. Acá el cierre
sale del GRAFO REAL de recetas (deps.build, resolución hermano→padre, la misma
que usa hammer) y el artefacto se elige por `hammer hash`. Cero ambigüedad, y si
falta algo el reporte dice qué receta y con qué hash lo buscaba.
Auditado además el cierre DINÁMICO del rootfs (452 ELF, 104 librerías NEEDED).
Sin resolver quedan cuatro, y sólo dos son hallazgos:
libc.so 359 consumidores — es el propio musl (en musl el loader
ES libc); lo aporta la base metal, no es hueco.
libc.musl-x86_64.so.1 17 artefactos (nss + spidermonkey) piden ESTE soname en
vez de libc.so. Es la MISMA libc: los construidos con el
gcc/clang de Alpine emiten un soname distinto al de
zig-cc. No rompe si el rootfs trae los dos nombres, pero
es una fisura de consistencia a documentar.
libstdc++.so.6 + SÓLO libmozjs-128.so y js128 ⇒ **la sesión GNOME arrastra
libgcc_s.so.1 el runtime C++ de Alpine por la cadena
gnome-shell → libgjs → libmozjs**. Es exactamente la
"última milla" de matar-gcc que se cerró para cmake con
-static-libstdc++ y que spidermonkey no cubrió. Radio
medido: 2 sellados (gjs, gnome-shell). Pendiente, y
conviene en el worker: el compile de mozjs come ~8GB+.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Las 3 fronteras que el propio comentario de gnome-desktop declaraba:
iso-codes receta nueva (datos + .pc). Ya no está en download.gnome.org (404);
Salsa es GitLab y su archive no es determinista (lección de cairo-shared),
así que la fuente es el .orig.tar.xz inmutable del pool de Debian.
Sin traducciones: i18n.gettext exige msgfmt completo y sólo hay
gettext-tiny — se vacían los 8 meson.build de dominio.
libseccomp receta nueva (autotools estático, gperf de build-dep real).
xkeyboard-config duplicada desde incoming-kde: fichero idéntico ⇒ MISMO ArtifactHash
(b3:bcf9b766) ⇒ ya está SELLADO. Frontera cerrada con cero rebuild.
Y un punto ciego del latido: cosecha-cron regeneraba build-state.json y el de KDE, pero
NUNCA el de GNOME. El grafo llevaba días mintiendo `never` sobre gobject-introspection y
toda la onda 2, que están selladas. Un grafo viejo miente con la misma cara que uno fresco.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Las 3 raíces de la onda 1 (gsettings-desktop-schemas, gobject-introspection,
gnome-desktop) copiadas a una cola propia + añadida al QUEUES del worker-loop.
Aislar evita que el worker dispare rebuilds de spidermonkey/mutter (onda 2/3).
El cierre real de RECETAS de las 3 es 39 nodos, 0 en incoming-kde: el "gap del
resolver" que temía era de seed-edges (.pc cairo→libX11), NO de deps declaradas.
El espinazo corpus (38 nodos: gtk4/cairo/pango/gdk-pixbuf en recipes/) está 100%
sellado ⇒ el worker cachea todo e sólo construye las 3. Hashes byte-idénticos a
los sellos de incoming-gnome (base_dir no entra al hash).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
drenar --perfil escritorio-gnome erraba 'no encontré un grafo (¿corriste
build-state.py?)' — mentira: el grafo GNOME existe, pero NINGUNA raíz del perfil
(mutter/gnome-shell/gdm) está autorada ⇒ 0 nodos etiquetados con el perfil, y la
comprobación por etiqueta en cargar() fallaba. Dos correcciones:
· cargar(): una cola NO-corpus identifica su grafo unívocamente ⇒ matchear por
cola; la etiqueta sólo desambigua dentro de corpus (perfiles solapados).
· main(): ámbito vacío con perfil ≠ 'imagen completa' — es 'SIN CIERRE todavía:
0 raíces autoradas', y apunta a autorar top-down.
KDE (162 sellados) y corpus (769/4-deuda) sin regresión. El yupana-gnome queda
armado en las 4 capas: objetivo, radio/grafo, keystones y drenar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El motor JS de Mozilla, keystone del frente GNOME (gjs→gnome-shell cuelgan de él),
sella b3:c48dbd10: libmozjs-128.so + libjs_static.a + shell js128 + mozjs-128.pc +
550 headers. Compiló el C++ entero a -O3 -j8 sin OOM en el hub (31GB/0 swap, al filo).
Seis capas iteradas desde el borrador:
1-2. FIX DE INFRA: el rootfs builder tenía clang pero NO el paquete (Alpine
separa los binutils LLVM del compilador). Sin llvm-ar/llvm-objdump/llvm-profdata
configure aborta. bootstrap-devfs.sh: +llvm22 en NEEDED + paso 3a-ter que
symlinkea la suite llvm-* a /usr/bin (Alpine la deja en /usr/lib/llvm22/bin sin
exponerla). Aplicado ya al rootfs real (.dev-fs/alpine).
3. triple: mozjs no traduce vendor ; 4. debe COINCIDIR con el host de rustc
(); fijado ese exacto.
5. venv de mach: cada fase es un bwrap con /tmp fresco ⇒ el venv no sobrevivía de
configure a compile. Anclado a /src/.mozbuild (persiste, es scratch).
No re-hashea artefactos (el rootfs no entra al hash). OJO: el worker golden también
necesita llvm22 (ya cubierto por bootstrap-devfs.sh para la próxima provisión).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build-state.py gana --gnome (análogo a --kde): carga recipes/incoming-gnome/ y sale
a build-state-gnome.json. drenar/yupana/seed-graph aprenden ese grafo (keystones,
drenar --perfil escritorio-gnome, radio de nodos incoming-gnome, frontera). Hoy la
cola está vacía ⇒ el grafo trae 0 nodos gnome; se poblará al aterrizar recetas y ahí
drenar/keystones reckonan solos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. targets.toml: [perfil.escritorio-gnome] (9 raíces de sesión, cola incoming-gnome)
⇒ `yupana objetivo` ya lo ve; fluye por targets.py. build-state lo SALTA hasta
que la cola se cargue (--gnome), así declararlo no reporta raíces fantasma.
2. seed-gnome.py OPTIMIZADO: hornea la triage (sustitución/tooling/opcional/espinazo)
como HIPÓTESIS con el caveat "el cierre de nixpkgs sobreestima ~5-10×", marca el
keystone (spidermonkey→gjs→gnome-shell), y deja de mentir con el 465 crudo. El
espinazo (353) se reporta como COTA ALTA, no como lista de build.
Falta (se activa al autorar): grafo build-state-gnome + flag --gnome ⇒ keystones/
drenar nativos sobre GNOME. Hoy la capa OBJETIVO del yupana-gnome está; la GRAFO no.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El loop del worker está SIEMPRE vivo (idle-loopea con la cola vacía). Contarlo
en hay_trabajo() reseteaba los ticks a 0 cada 10 min ⇒ el worker NUNCA acumulaba
idle ⇒ NUNCA se auto-mataba. Quedó 2h17m idle quemando € (2ª vez).
El loop construyendo YA se detecta por su hijo `hammer build`; el loop vacío NO
es trabajo. Se afina el match a `release/hammer.* build ` y se suma `campana-deuda`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cazado con el 1er worker real: el token vive en /etc/hammer-deadman.env (lo carga
systemd como EnvironmentFile), pero corrido A MANO (farm-up --puede-borrar) ese
fichero no se lee solo ⇒ mi cadena de fallback no lo veía ⇒ decía 'sin token' y
farm-up habría DESTRUIDO un worker que sí puede matarse. Fix: sourcear el
EnvironmentFile antes de la cadena de tokens desnudos. Verificado en worker real:
'SÍ: puedo borrar mi id=154276696'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El worker DEBE matarse solo, sin depender de la laptop (es la razón de ser del
dead-man: vive en el worker; el volumen hace que morir no pierda nada). El reaper
del hub queda sólo como último recurso. El dead-man estaba TRIPLEMENTE roto:
1. TOKEN EN EL FICHERO EQUIVOCADO: hay 2 caminos de creación con el token en
lugares distintos (farm-up → /etc/hammer-deadman.env; harkaq-vol → /root/
.hcloud-token). La service lee sólo el primero ⇒ un worker del otro camino
quedaba sin token, disparaba pero salía 1 "sin HCLOUD_TOKEN". Fix: deadman.sh
busca el token EN CADENA (volumen primero, que es lo más persistente).
2. REGEX DEL ID ROTO: la API devuelve JSON con espacio ("id": 126, no "id":126)
y el dead-man usaba '"id":[0-9]+' ⇒ NUNCA resolvía su id, aun con token
válido. Fix: '"id":[[:space:]]*[0-9]+'. (Doblemente roto: por eso NUNCA murió.)
3. FIRING ≠ CAN-DELETE: farm-up sólo verificaba que el timer dispara. Ahora
corre `deadman.sh --puede-borrar` (token en cadena + ve su id en la API) y si
NO puede matarse, DESTRUYE el worker ahí mismo. Un worker que no se autodestruye
es inadmisible ⇒ jamás debe existir.
gioser DOBLE-BLINDADO en TODOS los sitios de borrado (lista negra por nombre +
label role=hammer-worker), igual que farm-down ya tenía: reaper, farm-up-destroy,
deadman, y el helper de harkaq-vol. "Ahí está nuestra vida." Verificado vivo (104d).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sin esto un server borrado se quedaba en .fleet para siempre (describe falla ⇒
'sin label' ⇒ nunca se dropeaba). Ahora si no existe en hcloud, se saca.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
INCIDENTE (2026-07-23): un worker quedó 3.5h idle quemando € mientras el usuario
dormía. El dead-man del worker estaba "activo" (timer disparando cada 10 min)
pero FALLABA con exit 1 en cada tick: llegaba a matarse pero su
/etc/hammer-deadman.env no tenía token válido ⇒ salía 1 "sin HCLOUD_TOKEN" y
nunca se borraba. Verificaron que el timer DISPARA, no que puede BORRAR —
firing ≠ can-delete.
Causa de fondo: la garantía dependía de que CADA worker se auto-provisione bien
un token, y eso falla EN SILENCIO. Un invariante que depende de una provisión
frágil no es un invariante.
FIX en dos capas (defensa en profundidad):
1. REAPER HUB-SIDE (cosecha-cron.sh): el hub tiene un token que funciona y el
latido corre cada 30 min aunque no haya sesión (setsid). Un worker sin trabajo
activo REAPER_MAX=2 ciclos (~1h) se BORRA desde el hub, con el MISMO blindaje
de label role=hammer-worker (gioser jamás pasa). Cubre "dead-man del worker
roto". El dead-man del worker sigue cubriendo "hub caído".
2. farm-up verifica CAN-DELETE, no sólo firing: que el token del worker vea su
propio id en la API (lo mismo que hace al morir). Si no puede, LO GRITA al
provisionar en vez de descubrirlo 3.5h tarde.
Acción inmediata tomada: worker borrado a mano (label verificado), €0 baseline
restaurado — sólo queda gioser (protegido, fijo). Todo estaba cosechado.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El grafo modela deps (R1: Receta×Receta). El entorno es OTRA capa: R2 (Receta×
Capacidad, qué exige un build) y R3 (Host×Capacidad, qué ofrece la máquina).
Construible = clausura(R1) ∧ R2⊆R3. Esos huecos viven FUERA del grafo (infra del
sandbox, toolchain, layout de fs) y una matemática de grafos sola no los ve —
pero SÍ se aprenden, como patrones con predictor, calibrados contra fallos reales.
`yupana preflight <receta>` corre los patrones ANTES de construir. Primer patrón:
overlay-lowerdir, la cicatriz de kio. Habría marcado kio antes de quemar la
campaña descubriéndolo a los golpes:
kio: 52 deps ⇒ lowerdir ~5346B > 4096B (una página) ⇒ depende del merge
CALIBRADO, no adivinado: el estimador daba corto (2974B "ok") hasta usar el
prefijo que bwrap VE en runtime (/oldroot/…, ruta del worker, no la del hub).
Con eso: kio estimado 5346B vs real 5315B — 31B de error, sin fudge. El primer
predictor "obvio" mentía con confianza; la lección de siempre.
preflight --perfil escritorio-kde destapa lo sistémico: las 40 recetas KDE
profundas CUELGAN del merge (plasma-desktop: 114 deps → 11708B, casi 3× el
límite). Todo el escritorio dependía del fix 6a0dc40. El pre-flight vuelve
VISIBLE y contable una fragilidad que antes sólo se manifestaba fallando.
Registro extensible (PATRONES_ENTORNO): futuros = capacidad-de-host (rootfs
laptop≠worker, techo MSRV — acumular observaciones), skew de toolchain (zig-skew
— fingerprint por artefacto). harkaq ya MIDE R2 (evidencia negativa); esto lo
vuelve consultable + pre-vuela. Guardián: kio marcado + estimador calibrado.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
merge_deps_layer funde las deps en UNA capa overlay por hardlinks (cp -al) para
no desbordar el lowerdir de overlayfs (tope ~4096B por página). Pero calculaba
la ruta del merge en deps.parent().parent() = <store>/../.dmerge, asumiendo que
el store está en <base>/store del MISMO fs. En el worker el store es un VOLUMEN
bind-montado (/opt/hammer/store en /dev/sdb, /opt/hammer en /dev/sda1) ⇒ el merge
caía en la raíz, cp -al fallaba cross-device, y el fallback apilaba las ~50 deps
de kio directo → lowerdir 5315B > 4096B → "bwrap: Can't make overlay mount" →
kio (EL keystone, gatea 36) imposible de construir. Por eso estaba clavado.
Fix de una línea: el merge va en el padre INMEDIATO de las deps (= el store
mismo), garantizado mismo fs. Confirmado en el worker: cp -al a /opt/hammer/.dmerge
falla "Invalid cross-device link"; a /opt/hammer/store/.dmerge funciona.
En el laptop andaba por casualidad (store y su padre en el mismo fs).
`.dmerge` (dot-prefix) es invisible para el store lookup/build-state como .times.
cosecha excluye /.dmerge del rsync (es scratch de hardlinks; sin -H rsync lo
expandiría a copias reales).
Descubierto levantando el worker para poblar tiempos: yupana keystones apuntó a
kio, la campaña falló ahí, y el radio de lowerdirs (5315B) destapó la causa. Una
"sorpresa de entorno" (infra del sandbox, fuera del grafo) que el frente de
timing sacó a la luz.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El peso que le faltaba a las ondas: sin él, critical-path = nº de pasos; con él,
ETA real en segundos.
INSTRUMENTACIÓN (worker):
scripts/farm/build-timed.sh envuelve `hammer build` midiendo la pared y la
registra en $STORE/.times/<hash>-<host>.json — un SIDECAR del store, NUNCA
dentro del artefacto. La duración es no determinista (varía por máquina/carga)
⇒ no puede tocar el ArtifactHash. Keyed por hash (CAS-safe: dos workers no se
pisan) + host (varias muestras por receta). Viaja al hub con el rsync de store
que ya hace la cosecha; ninguna herramienta del store lo confunde con artefacto.
Sólo builds REALES (pared ≥ UMBRAL 3s) — los cache-hits no envenenan la mediana.
campana-deuda.sh ahora construye vía build-timed.sh (transparente, mismo exit).
CONSUMO (hub): yupana._tiempos() carga name→mediana de segundos; keystones
computa el CAMINO CRÍTICO pesado = longest weighted path del subgrafo de deuda
(peso = segundos de build). La cadena más larga hay que construirla en SERIE
aunque haya ∞ workers ⇒ es la ETA con paralelismo infinito. Degrada con gracia:
sin datos, peso=1 y el camino crítico = nº de pasos (lo que ya daban las ondas).
Verificado: con muestras sintéticas da "ETA 19 min sobre 5 pasos, kio → kparts →
frameworkintegration → breeze → plasma-integration"; sin datos degrada a "5 pasos
(SIN datos)". store/.times está git-ignorado (metadata de máquina, no se commitea).
LÍMITE honesto (en la cabecera de build-timed.sh): `hammer build` arrastra deps ⇒
la pared incluye deps no selladas. En orden topológico (drenar) las deps ya están
selladas y la pared mide sobre todo ESTA receta — cota superior buena para pesar.
La precisión exacta pediría instrumentar el sellado dentro de hammer-build (Rust).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El usuario pidió arreglar itstool y la sombra de dbus. Apliqué el reflejo
(`yupana radio` + diff completo antes de tocar) y NINGUNO era un bug: ambos eran
patrones deliberados y documentados que mi detector, demasiado superficial, leyó
mal. Si los "arreglaba" a ciegas rompía dos builds.
· dbus: corpus/dbus (estático) e incoming-kde/dbus (dinámico) NO son redundantes
— qtbase linkea libdbus-1.so dinámicamente para Qt6DBus. El detector comparó
sólo versión+sha+deps, no el BUILD. Borrar la sombra rompía KDE.
· itstool: es un STUB documentado (`[source]=carrier`). El itstool real es
Python con libxml2-bindings ausentes en el lab; este genera un script inline y
sólo PRESTA el tarball de gettext-tiny. Apuntarlo al itstool real rompía
appstream (radio 5) con un source que ni compila acá.
Los 3 *-hello son el mismo patrón carrier; prison/prison-scanner una variante
cross-nombre (misma fuente, WITH_ZXING distinto).
FIX = el detector, no las recetas. Tres señales que le faltaban:
1. huella de BUILD en la firma de sombra (estático≠dinámico ⇒ no redundante).
2. CARRIER: ≤1 receta del grupo construye la fuente ⇒ el resto presta el tarball.
Señal: ¿invoca make/meson/cmake/ninja/cargo? (comentarios strippeados — la
prosa "invocación de meson" de un stub daba falso builder, lo cazó el guardián).
3. variante cross-nombre: ≥2 builders con BUILD distinto = deliberado, no bug.
Resultado: 0 colisiones reales (invariante c se cumple), 4 carrier + 14 variantes
+ 12 sombras justificadas, todas benignas.
Guardián extendido: ancla que el detector agrupa por FUENTE (itstool↔gettext-tiny)
y NO cría lobos (itstool=carrier, dbus=variante deliberada).
LECCIÓN: el reflejo del radio/diff antes de tocar evitó dos borrados destructivos
guiados por un falso positivo de mi propia herramienta. Medir antes de creer, aun
a la propia yupana.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`yupana keystones` rankea los nudos EN DEUDA por cuánto trabajo BLOQUEADO libera
su sellado (cierre inverso transitivo restringido a la deuda). Sellá de arriba
hacia abajo y la cascada cae lo antes posible.
CORRIGE UN ERROR MÍO: dije "árbol de dominadores = keystones". Lo medí y es
FALSO. El dominador ingenuo asume alcanzabilidad OR (basta un camino), pero
construir es AND (hacen falta TODAS las deps): kcoreaddons depende de dbus/libdrm
/mesa además de qtbase ⇒ hay un camino de desbloqueo que evita qtbase y el
dominador-OR lo pierde. La métrica correcta bajo AND es el cierre inverso
transitivo. Y el crudo lo gana el toolchain (make gatea 313, inútil) ⇒ el
keystone accionable es el cierre restringido a la deuda, y sólo cuenta si el
nudo mismo está en deuda (un sellado ya está disponible, no gatea nada).
Estado actual (qtbase ya sellado, era EL keystone con 117 gateados):
kio 36 ← sellarlo libera 36 recetas KDE bloqueadas
kparts 19
kcmutils 17
ksvg 14
libplasma 13
Ése es el camino crítico ahora.
El dominador SÍ entra, en su uso legítimo: la columna `exclusivo` = retención
estilo GC (nodos que sólo dependen de mí). kwin=9 (sus plugins privados),
libksysguard=3. Responde "si suelto esta feature, cuánto más puedo soltar".
Sin datos nuevos (no necesita duración de build). Cuando instrumentemos tiempos,
el mismo cierre se vuelve camino crítico PESADO (ETA real).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`yupana duplicados` caza nudos que son el MISMO paquete bajo nombres/colas
distintos, agrupando por lo que BAJA (sha/url), no por el nombre. Clasifica:
colisión-fuente — mismo sha, familias de nombre DISTINTAS ⇒ casi seguro un
sha copiado por error. ACCIONABLE.
variante — mismo sha, misma familia (zlib/zlib-shared, mesa-*) ⇒
diversificación deliberada estático/shared, OK (14 vistas).
sombra — mismo nombre en >1 cola; redundante si versión+sha+deps
coinciden (una sobra), justificada si difieren.
HALLAZGO INMEDIATO — el detector encontró un bug real en su primera corrida:
itstool.toml dice versión 2.0.7 pero su url+sha apuntan al tarball de
gettext-tiny (29cc165e…). Construiría la fuente equivocada. radio itstool=1,
gettext-tiny=78 ⇒ el sha copiado es el de itstool.
Otras 3 colisiones son demos (adwaita-hello/libadwaita, sourceview-hello/
gtksourceview, radio 0) o subcomponentes (prison/prison-scanner) — a revisar,
no urgentes. Y 1 sombra redundante: incoming-kde/dbus es byte-idéntica a
corpus/dbus (mismo hash de artefacto) ⇒ los 114 consumidores KDE podrían
apuntar a corpus/dbus sin rebuild.
NO funde nada solo: reporta a docs/state/duplicados.json y deja el juicio al
humano (como el triaje). El radio de cada nudo dice cuál es el canónico (el de
mayor radio). Guardián extendido: test-yupana-radio.py falla si el detector
deja de ver colisiones de fuente (agrupar por nombre en vez de sha = invariante
c ciego).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`khipu` colisionaba con una app de tawasuyu. En vez de un nombre a dedo, el
significado más profundo RESUELVE la colisión: en los Andes el khipu GUARDABA
(el registro de nudos) y la yupana CALCULABA sobre él (el ábaco). Acá igual, y
la distinción es arquitectura:
docs/state/build-state.json = el KHIPU (el registro firme: nudos y cuerdas)
scripts/yupana.py = la YUPANA (el motor que reckona: radio, ondas, clausura)
Nunca fue del todo un khipu lo que construimos; era la yupana. La colisión
empujó al nombre más exacto.
git mv preserva historia. Verificado tras el rename: `yupana radio libdrm` da
126/132/[kde,mirada]; el guardián de regresión pasa; build-state importa yupana
y --check sigue en 0. Cero referencias `khipu` colgadas en código.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test-khipu-radio.py falla si el radio de un paquete compartido entre colas
vuelve a medirse recortado: verifica que libdrm lo consuman >1 cola y que su
membresía incluya escritorio-kde (la mentira original decía sólo mirada).
Regla que blinda: cuando khipu se equivoca, no sólo se arregla — queda el test
que impide repetirlo. Es la automejora arquitectónica hecha práctica.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El 2026-07-22 un cambio a libdrm (default_library=both, para gtk4) invalidó 118
recetas KDE. El radio se midió contra recipes/*.toml y no contra
recipes/incoming-kde/ ⇒ 8 en vez de 118. Y build-state.py sin --kde cometía el
MISMO error: cargaba sólo el corpus, y su campo `perfiles` AFIRMABA que tocar
libdrm sólo afectaba a escritorio-mirada. Una mentira con confianza.
Causa estructural: las dependencias INVERSAS son un hecho del disco entero, no
de la vista que uno cargó. khipu.py las calcula SIEMPRE sobre todas las colas,
con resolución sibling-first (el nodo se identifica por (cola, nombre), porque
corpus/fontconfig y incoming-kde/fontconfig son nudos distintos).
QUÉ NODOS PUNTÚO es decisión de vista. QUIÉN ME CONSUME es un hecho.
khipu es la PUERTA ÚNICA de la metodología: radio/perfiles nativos (cruzan todo
el repo) + delega estado/drenar/triaje/frontera/objetivo a sus órganos.
khipu radio libdrm → 126 directos, 132 transitivos, [escritorio-kde,
escritorio-mirada], 11 sellados que caen. El número
honesto que habría frenado el cambio.
build-state.py ahora saca `perfiles` y el nuevo `dependientes_total` de khipu
(grafo entero), no de los nodos que cargó. Verificado: el grafo por defecto ya
atribuye libdrm a escritorio-kde; sin_perfil bajó 674→666 (8 recetas del corpus
que sólo KDE consume, ahora bien atribuidas). --check sigue en 0, grafo cierra.
Repo-wide por diseño, no KDE-céntrico: zlib toca las 4 imágenes (233 transitivos),
make 313.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Evidencia dura primero: NINGUNO de los 170 bloquea un build. Toda receta que
pide uno de estos ya está sellada al menos una vez ⇒ hammer construye sin
ninguno. Eso descarta empíricamente "hueco de build" para los 170 y deja sólo
la pregunta de runtime.
VEREDICTOS
provisto 11 — no faltan: ya los da otra receta con otro nombre. nixpkgs parte
en varios paquetes lo que acá es uno solo. Verificado contra el
store: wayland-scanner→wayland, mesa-libgbm→mesa (nuestros tres
mesa producen libgbm.so.1), libxcb-*→xcb-util-*, poppler-qt6→
poppler, gmp-with-cxx→gmp, uname→coreutils.
nix-ismo 5 — andamiaje de nixpkgs (env wrappers, helpers del stdenv, glibc
asomando por su libc).
opcional 150 — software real que nixpkgs habilita y hammer no necesita, con el
porqué agrupado: systemd (usamos arje-zero), X11 heredado (el
escritorio es Wayland), Vulkan/shaders, audio/multimedia,
conectores de BD, tooling de docs/tests, bindings Python de Qt,
paquetería ajena (tenemos .swm).
hueco 4 — gaps de RUNTIME, no de build: shared-mime-info (sin base MIME
no hay tipos de fichero), xwayland (ninguna app X11 corre),
polkit-qt-1 (sin diálogos de autorización), qqc2-breeze-style
(los controles QML caen a un estilo genérico).
CORRIGE UN ERROR MÍO DE P3: dije que mesa-libgbm/libglvnd eran "el muro de
GBM/EGL". Falso — libgbm ya lo produce nuestro mesa. El muro era softpipe vs
llvmpipe, no un paquete ausente.
CUARTO VEREDICTO NUEVO (`provisto`) con su propio lazo: sale a alias-triaje.txt
y seed-graph.py lo carga en MAPA ⇒ esos 11 nombres dejan de contarse como hueco
para siempre. Junto con nixismos-triaje.txt, el sembrador aprende de su triaje.
Y LA CADENA SE EJERCE ENTERA POR PRIMERA VEZ: los 4 huecos entraron como raíces
de escritorio-kde → nacieron 4 nodos `wanted` (raíces 11, sin receta 4; el grafo
sigue cerrando, --check exit 0) → seed-graph los sembró (42 aristas conocidas,
21 candidatos nuevos de frontera) → drenar.py los ordena marcándolos [semilla],
que es la regla de la fuente única a la vista. qqc2-breeze-style no cae en la
onda 1 porque sus deps SEMBRADAS la traban (kcodecs, kirigami…): el andamio
funcionando como se diseñó.
De paso: qtbase se selló mientras corría esto ⇒ la onda 1 de KDE se abrió de 1 a
13 recetas. El cuello de botella que reportó P4 ya está destrabado.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tandas/README.md declara los 55 ficheros ARCHIVO de campañas pasadas (no se
borran: su git log es la crónica de cada frente) y documenta de dónde sale el
trabajo ahora. import-batch.sh -f sigue vivo como vía de escape, no como camino.
Lo que faltaba para que la cola fuera derivada de verdad era el paso humano, y
estaba sin forma: 137 candidatos clasificables sólo en la cabeza de alguien.
triaje.py les da forma durable — docs/state/frontera-triaje.toml, 170 candidatos
unificados de los 3 perfiles. Cada veredicto cierra un lazo distinto:
hueco → raíz en targets.toml ⇒ nace un nodo `wanted`, el sembrador lo
siembra y el drenaje lo ordena (= trabajo nuevo para el worker)
opcional → registrado, deja de reaparecer como pregunta
nix-ismo → vuelve a seed-graph.py como descarte ⇒ el sembrador APRENDE de su
propio triaje y cada corrida sale más limpia
Sincronizar NUNCA pisa un juicio humano: agrega los nuevos y marca ausente=true
los que dejaron de aparecer (progreso, o cambio de nixpkgs — se ve en el diff).
`sugerencia` propone con evidencia mecánica pero `veredicto` arranca en
`pendiente`: la heurística abarata el juicio, no lo cierra.
Lazo verificado punta a punta: python3-3.14.6-env marcado nix-ismo → --aplicar
lo escribe al descarte → seed-graph.normalizar() lo devuelve clasificado y deja
de contarlo como hueco. Probado y revertido; los 170 siguen pendientes.
Cadena cerrada y corriendo: targets.toml → build-state.json → drenaje.json →
worker, regenerada por el latido cada 30min. El único paso que pide una persona
es el triaje.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`build-farm.sh` usa `xargs -P$JOBS`; con >1, dos recetas que comparten dep disparan la carrera del
árbol de fuentes del ADR 0012 y el árbol queda roto para siempre. Se vio a escala: al invalidar
libdrm, 118 de las 205 recetas KDE pasaron de cache-hit a rebuild real y ~93 murieron con
"/src/.zwrap/cc is not a full path to an existing compiler tool" — el wrapper de zig que la receta
deja en el ÁRBOL DE FUENTE, barrido por el fetch concurrente de otra receta sobre el mismo qtbase.
El reintento serial de build-farm tampoco las recupera: el árbol ya quedó inconsistente.
La caché estaba ocultando la carrera, no evitándola. Serializar es la única mitigación correcta
hasta que el ADR 0012 elija salida.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
drenar.py parte la deuda en ONDAS topológicas (una receta sólo espera por deps
que también estén en deuda; las selladas ya están en el store). Onda 1 =
construible ya; dentro de cada onda, orden por `unblocks` desc.
Da lo que una lista plana no da: el orden correcto, la PROFUNDIDAD real de la
cadena (pasos secuenciales = el reloj de pared) y qué paraleliza sin pisarse.
base 0 en deuda imagen completa
cli 0 en deuda imagen completa
escritorio-kde 77 en deuda 12 ondas onda 1 = 1 receta: qtbase
escritorio-mirada 4 en deuda 2 ondas onda 1 = 3
HALLAZGO: el escritorio KDE está serializado detrás de UNA receta. qtbase
destraba 117 nodos y es lo único de la onda 1 — hasta que no esté sellada no hay
nada que paralelizar, por más workers que se enciendan. Cambia la pregunta de
"¿cuántas faltan?" (77, poco informativo) a "¿cuál es el camino crítico?".
Dos correcciones salidas de mirar la salida:
· el grafo se elige por la `cola` declarada en targets.toml, NO por cuál tiene
más nodos: incoming-kde SOMBREA recetas canónicas, así que medir
escritorio-mirada contra el grafo KDE medía una imagen que nadie construye
(3 en deuda en vez de 4). El mismo atajo estaba en seed-graph.py --frontera;
corregido en los dos.
· regla de la fuente única implementada en deps_de(): manda la receta si existe,
la semilla sólo si el nodo es `wanted`, marcando procedencia en la salida.
Enchufado al latido: cosecha-cron regenera docs/state/drenaje.json junto al
grafo y lo commitea ⇒ cola derivada y versionada, el git diff entre ciclos
muestra qué salió de la deuda. NO lanza builds: eso cuesta € y sigue siendo
decisión explícita (farm-up + campana-deuda.sh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
seed-graph.py extrae deps de la metadata de nixpkgs sin construir nada (leerla
no viola el ADR 0004, que prohíbe *construir* con nix). `--calibrar` mide la
semilla contra la única verdad disponible: las recetas escritas a mano.
VEREDICTO (60 recetas, 686 deps):
directas recall 39%*, precisión 59% (269 de 455)
transitivas recall 84%, precisión 6% (390 de 6760)
* contra la verdad APLANADA — comparación injusta, y el modo transitivo
prueba que la información sí está; sólo falta aplanarla.
Decisión: sembrar aristas DIRECTAS, la transitividad la hace el grafo. hammer
aplana la clausura en [deps].build (cada dep = capa --overlay-src), nixpkgs
declara directas y propaga; reproducir el aplanado arrastra 4629 nodos fantasma
del bootstrap de nixpkgs.
Lo que enseñó la calibración (3 rondas, cada fix salido de un dato):
· 4 clases de discrepancia, no 1: convención hammer (make/binutils/linux-headers,
implícitas en el stdenv de nix), variante nuestra (zlib vs zlib-shared),
provisto por el lab (cargo/rustc — las recetas Rust declaran deps=[]), y recién
después ruido real. Contarlas juntas hacía parecer irreducible lo clasificable.
· desalineación aburrida y arreglable: caja (libx11/libX11), implementación
(gettext→gettext-tiny, ninja→samurai), versión pegada al pname, hooks del
stdenv. Precisión 45%→59%; "sin attr en nixpkgs" 22/40 → 6/60.
· el escritorio KDE cuelga de kdePackages., no del top-level: sin ese fallback
el perfil objetivo era insembrable.
Hallazgo de secuencia: hay 0 nodos `wanted` (targets.toml se pobló por
lift-and-shift de lo existente), así que el sembrador no tenía a quién sembrar.
De ahí `--frontera <perfil>`: siembra las recetas que YA existen y reporta lo que
nixpkgs pide y hammer no puede construir. 137 candidatos en escritorio-kde
(kdoctools ×6, milou, polkit-qt-1, libkscreen, y mesa-libgbm/libglvnd/spirv-tools
= el muro de GBM/EGL ya documentado), 51 en cli, 46 en escritorio-mirada.
Cada candidato es una HIPÓTESIS a clasificar a mano: dep opcional no habilitada,
nix-ismo por filtrar, o hueco real. Nada se promueve a receta automáticamente.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El grafo cruza ahora el corpus con el manifiesto de objetivo:
· estado `wanted` — una raíz declarada sin receta se materializa como nodo de
frontera. Se crea ANTES de buscar huérfanas a propósito: un objetivo declarado
no es una arista colgando, así `--check` conserva su significado (verificado:
exit 0, grafo CIERRA, topo OK). `wanted` ≠ `unhashable`.
· campo `perfiles[]` por nodo — qué imágenes lo alcanzan desde sus raíces.
· bloque `by_profile` — el "cuánto falta", POR IMAGEN.
Primeros números, derivados de raíces y no de listas a mano:
base 51/51 listo
cli 74/74 listo
escritorio-mirada 27/31 (falta libinput, mesa-swrast, mirada-{compositor,greeter})
escritorio-kde 85/162 faltan 77 ← de 7 raíces, antes ilegible
674 recetas no las alcanza NINGUNA imagen (catálogo, no distro)
La clausura es COTA INFERIOR mientras haya nodos `wanted`: sus deps no se
conocen hasta construirlos (o sembrarlas, P3). Está documentado en el header.
`totals.recipes` sigue contando sólo recetas con fichero (los consumidores lo
leen como tamaño del corpus); `totals.nodes` incluye la frontera. La vista HTML
regenera sin cambios de contrato.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
git.tawasuyu.net resuelve a gioser, marcado como FIJO y SIN BACKUP. Sin espejo, las dos únicas
copias del repo eran el laptop —que ya se corrompió una vez por un corte sucio, el 2026-07-22— y
esa. Ahora hay una tercera, en otro proveedor y PRIVADA.
Se implementa con `git remote set-url --add --push origin` en vez de un remoto `github` aparte,
para que TODO `git push origin main` que ya existe en los scripts (cosecha-cron.sh, el latido)
espeje solo, sin tocar un script. Si GitHub falla, el push devuelve != 0 aunque gitea haya
aceptado; cosecha-cron.sh ya lo tolera ("push falló, reintenta próximo ciclo") ⇒ el latido no se
rompe por eso.
La credencial va por HTTPS con el token de `gh`, no por SSH: las tres claves SSH del laptop
(github5, key25, sergiogithub) son DEPLOY KEYS de repos ajenos y dan "Repository not found" contra
este. El bloque `Host github.com` del ~/.ssh/config además no tiene `IdentitiesOnly`, así que ssh
ofrece todas y gana una deploy key.
El script existe porque las dos cosas que configura viven en `.git/config`, que NO se versiona: se
perderían exactamente en el escenario para el que se pusieron (el laptop muere, clonás de nuevo).
Incluye también el `core.fsync` del mismo incidente. Idempotente (los pushurl son acumulativos, así
que los reconstruye en vez de añadir) y con `--check` para auditar sin tocar nada.
Verificado: los dos remotos en el mismo commit, GitHub reporta PRIVATE, y correrlo tres veces deja
2 pushurl, no 6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El destino de la distro vivía en 2 strings de product-userland-from-repo.sh, 1
de mirada-usb.sh y 55 tandas planas. Ahora en docs/state/targets.toml: un perfil
= una imagen enviable, listando sólo las RAÍCES (lo que se pide por nombre); la
clausura la calcula el grafo, no un humano.
4 perfiles: base (26), cli (46, hereda base), escritorio-mirada (14),
escritorio-kde (7 raíces, cola incoming-kde). scripts/targets.py expande hereda
(transitivo, con detección de ciclo) preservando el orden — mirada lo necesita.
Lift-and-shift verificado: las 3 listas expandidas son byte-idénticas a los
strings que reemplazan. Ninguna imagen cambia de contenido.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El `echo "ciclo: N recetas en $Q"` sale ANTES de pedir el lock, así que con la campaña corriendo el
journal mostraba el anuncio del ciclo y después nada: parece un loop trabajando y es un loop
bloqueado. Es la misma clase de problema que el "no encuentro el ejecutable zig" apuntando al
directorio equivocado — un log que miente cuesta horas de diagnóstico.
`flock -n` primero, y sólo si falla se anuncia la espera y se bloquea. Sin coste cuando el lock
está libre, que es el caso normal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Los dos construyen sobre el MISMO work/, y `fetch` nombra el árbol de fuentes de forma determinista
(`work/sources/<receta>-<sha16>`, sin nada que dependa de QUIÉN construye) ⇒ dos procesos que
necesiten la misma DEP apuntan al mismo directorio: uno hace `remove_dir_all` mientras el otro
corre `tar -x`. El árbol queda a medias y devuelve "Directory not empty" (os error 39), y así se
queda hasta que alguien lo borra a mano.
No es teórico: la campaña de deuda y el loop pidieron `mesa` a la vez (el loop lo arrastraba desde
la cola KDE al invalidarse libdrm) y se llevó puesta media cascada GUI, con un error que no nombra
la causa. Casi borro ese árbol a mano antes de ver que había un bwrap montándolo.
Grano: la campaña toma el lock para toda su corrida; el loop, por ciclo de cola. Más fino rompería
el `xargs -P2` de build-farm.sh.
Dos honestidades en los comentarios, para no prometer de más:
- `flock` NO es FIFO. El loop vuelve a pedirlo enseguida y le gana a la campaña que espera:
medido, la campaña entra al terminar TODAS las colas, no entre dos. La ventana real es el
IDLE_SLEEP. Por eso espera con techo (LOCK_WAIT=7200) y sale limpia en vez de colgarse.
- Esto NO cierra la carrera del todo: el `-P2` interno del loop puede correr dos recetas de la
misma cola que compartan dep, y ésas se siguen pisando. El arreglo de fondo es un lock POR
ÁRBOL dentro de `fetch`, que cubriría los dos casos.
Verificado con flock real: exclusión mutua (la campaña entra justo cuando el loop suelta), salida
limpia por timeout con rc=0, y la no-equidad de flock medida en vez de supuesta.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regresión introducida ayer al mover el store al volumen persistente (`ln -sfn /mnt/cosecha/store
$REMOTE/store`). El síntoma se leía como deuda de la cascada GUI y era otra cosa.
hammer deriva el lab del PADRE DEL STORE: `BuildConfig::defaults_for_store` hace
`project_root = store_root.parent()` y de ahí `.dev-fs/{alpine,tools/zig,cache}`. Con el store
symlinkeado, resuelve a /mnt/cosecha/store ⇒ busca /mnt/cosecha/.dev-fs, que no existe, y muere con
"no encuentro el ejecutable zig en …" — un mensaje que apunta al lugar equivocado: el zig 0.13.0
está, y está bien, sólo que en /opt/hammer/.dev-fs/tools/.
Golpea SÓLO a las recetas que fijan `zig_version` (20 en el corpus), porque son las únicas que
resuelven un zig hermano del por defecto. Por eso la campaña del 05:41 dio 26 selladas / 11
fallidas y las 11 eran exactamente ésas (tllist pango gtk4 libadwaita gtksourceview fcft
*-hello hammer-edit dwarves), y por eso la de las 19:48 —que era justo la lista de deuda— dio 0/14.
El bind-mount mantiene las DOS invariantes a la vez: los datos siguen en el volumen (sellar =
persistir, que es la razón del volumen) y `$REMOTE/store` vuelve a ser una ruta real bajo $REMOTE
⇒ el padre es /opt/hammer y .dev-fs se encuentra. Verificado en el worker vivo: con el bind-mount
y SIN overrides de entorno, libinput pasa la resolución de zig y falla por su dep real de Python.
Se persiste en fstab para que sobreviva al reboot, y el chequeo de verificación pasa de `test -L`
a `mountpoint -q`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El latido vivía sólo en el crontab y eso resultó frágil: este laptop arranca a veces con
`init=/usr/local/sbin/arje-zero` y a veces con OpenRC (KDE), y no hay systemd en ninguno de los
dos. `cronie` es un servicio OpenRC ⇒ en el arranque arje no existe y el latido no late. Peor: no
late EN SILENCIO. Se destapó tras un corte sucio del 2026-07-22, en el que además se vio el
runlevel `default` quedar a medias (cronie/metalog/acpid caídos, NetworkManager/dbus arriba) ⇒ ni
siquiera arrancando OpenRC era garantía.
El costo del síntoma es caro y callado: sin latido el hub no siembra, el worker agota la cola y se
queda idle quemando € sin moler.
`latido.sh` cuelga el latido de la SESIÓN: cualquier terminal, en cualquier arranque, asegura que
haya exactamente un latido vivo (`--ensure` desde ~/.zshrc, ~5ms y mudo si ya hay uno). Sin root,
sin unidad de servicio, sin mantener lo mismo por duplicado en dos inits. Contra asumido: no late
sin sesión abierta — es un laptop, no un server, y el primer ciclo dispara al instante de abrir la
terminal (el cron esperaba hasta 30 min al próximo tick).
El lock va DENTRO de cosecha-cron.sh, no en el llamador, para que valga venga de donde venga. Eso
además tapa un bug latente que ya existía: nada impedía que dos ciclos se solaparan haciendo rsync
sobre el mismo store y `git commit`+`push` a la vez. Con el lock, el crontab puede quedarse: bajo
KDE dispara cron, bajo arje dispara la sesión, y nunca se pisan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
En serie y no en paralelo: dos `hammer build` simultáneos compiten por el mismo work/sources y por
el watchdog de disco del worker-loop. El paralelismo vive DENTRO de cada build (-j$(nproc)).
En tandas y no en una lista plana: una tanda es una unidad topológica (la cadena GUI, el stack
wayland, los kernels). Cuando una se cae por su raíz —como pango arrastró a 7 recetas— se ve de un
vistazo en el resumen y se re-lanza sola; una lista de 40 nombres no dice nada al fallar.
Espera a que termine la campaña en vuelo, así se encola mientras otra muele.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El artefacto guarda su recipe.toml pero NO los hashes de sus deps, y el ArtifactHash sí los
incluye ⇒ dos sellados con el mismo recipe.toml pueden diferir LEGÍTIMAMENTE porque cambió una
dependencia. La primera versión gritaba "NO-REPRODUCCIÓN REAL" sobre 128 paquetes; eso era un
superconjunto, no una prueba.
Ahora separa por la CAUSA, que sí discrimina: si TODO lo que difiere son metadatos que un cambio
de dep no explica (MTIME de gzip, cabecera ar, ruta de build, secciones ELF informativas), es
no-determinismo probable; si difiere .text/.data o hay ficheros de más, es indistinguible de un
cambio de dep sin reconstruir. 128 → 92 probables + 36 no concluyentes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cuando un artefacto no reproduce, el store sólo sabe decir "el hash no coincide" y el resto es
trabajo artesanal. Esto responde POR QUÉ, en términos de la CAUSA y no del byte:
- gzip con MTIME embebido (bytes 4..8) → remedio: `gzip -n`
- cabecera `ar` de un `.a` (mtime/uid/gid) → remedio: modo determinista (`ar D`)
- secciones ELF, con lectura experta: sólo `.comment` ⇒ otra versión de compilador; sólo
`.debug_*` ⇒ rutas de build; sólo `.symtab`/`.dynsym` ⇒ orden de símbolos (código idéntico);
sólo build-id ⇒ residuo, no causa raíz. En un `.a` dice QUÉ MIEMBRO difiere.
- ruta del árbol de build embebida, texto (línea que difiere), y bytes como último recurso.
Y sobre todo trae la EVIDENCIA, no sólo la hipótesis: para las secciones de texto extrae las
cadenas que están en un ELF y no en el otro. Caso real que lo motivó (alsa-lib): la
interpretación decía "típicamente rutas de build" y la evidencia mostró
`/src/target/release/build/libsodium-sys-<hash-cargo>/out/…`. Sin la cadena era una corazonada;
reproducir eso a mano cuesta varios readelf, la herramienta lo da en 40ms.
Descenso, no comparación total: sólo baja donde los hashes difieren (el cruce con format/
reconcile del SDD 17). Sin dependencias externas — parsers gzip/ar/ELF propios, como manda el
ADR 0004: un diffoscope de verdad se apoya en medio mundo de binarios ajenos.
`--json` para el bucle agéntico; exit 0 si reproduce, 1 si diverge (encadenable en scripts).
`scripts/why-differs-barrido.sh` lo pasa por todo el store y separa los dos casos que se
confunden a ojo: recipe.toml distinto (divergencia esperada) vs recipe.toml IDÉNTICO y artefacto
distinto (no-reproducción a investigar).
5 tests nuevos; los 142 de hammer-core siguen en verde.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`saldar-deuda-static.sh` calcula la deuda en vivo con `hammer hash --check` contra el store
LOCAL. En el worker ese store es PARCIAL (el del volumen) ⇒ mide 716 en vez de 37. Es la regla
de siempre con otra cara: el worker MIDE, el hub CLASIFICA. Acá el hub decide (DRY=1) y el
worker ejecuta una lista explícita, con las raíces del stack GUI primero (glib unblocks=14 →
harfbuzz/cairo/pango → gtk4 → libadwaita) para que la cascada caiga cuanto antes.
Incluye el PATH del `go` del store: `go mod vendor` corre host-side y una sesión SSH no
interactiva no carga /etc/profile ni cargo/env. Y HOME por defecto, que systemd-run no hereda
(con `set -u` era fatal).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El anclaje del store al volumen hacía `rm -rf $REMOTE/store` antes de symlinkear. Eso borra
justo el catálogo cacheado que es la razón de ser de la imagen golden ("arranca con TODO el
catálogo ⇒ cache-hit instantáneo"). Consecuencia medida hoy: el worker calculó 716 recetas de
deuda donde el hub medía 37, y se puso a reconstruir media distro (fallando, además, porque
sin `go` en el PATH host-side las recetas Go mueren en `go mod vendor`).
Fix: fusionar en vez de borrar. El store es CAS (nombre = hash) ⇒ `mv -n` al volumen es seguro
por construcción: no pisa lo que el volumen ya tiene, y lo horneado queda disponible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Síntoma: el USB de escritorio arranca, se ve el kernel, "arje-zero: despierta como PID 1",
el remount de la ext4 y el stack-depth de netup… y ahí la pantalla se congela para siempre.
Causa: el CMDLINE horneado es "console=tty0 console=ttyS0,115200 …" y el kernel hace
/dev/console = la ÚLTIMA console= ⇒ ttyS0. La seed card de la base metal supervisa un solo
getty, sobre `console` ⇒ en una laptop sin puerto serie el shell nace INVISIBLE. Los printk
sí van a las DOS consolas: por eso se ven los mensajes del kernel y después silencio. El
sistema nunca estuvo colgado — reproducido en OVMF: por el serial hay shell root (PID 98,
sshd arriba); en pantalla, nada. Nunca se vio antes porque toda validación fue -nographic,
donde el serial ES la pantalla (y el comentario de install-image-efi.sh afirmaba lo contrario:
"console=tty0 al final ⇒ el stdout va a la PANTALLA").
Fix en el script de imagen, no en el cmdline: un getty sobre tty1 es independiente del
cmdline (en metal siempre hay VT) y no obliga a recompilar/re-sellar el kernel (~60min).
Se conserva el getty de `console` para debug por serial.
- seed card del rootfs fundido += nodo tty1-getty (clona console-getty, argv → tty1)
- /usr/bin/console-login: muestra el motd y exec sh. Exporta PATH: arje-zero lanza el getty
con envp VACÍO ⇒ sin él no se resolvía ni `cat` ni `plasma-start`.
- ambos escriben rompiendo el hardlink (os.replace / rm -f): $MERGED es cp -al de la base,
escribir in-place mutaba work/metal-rootfs y toda imagen que comparta el inodo — ya había
pasado con /etc/motd.
Validado en OVMF con pantalla (no -nographic): motd + prompt "/ #" visibles, y `ls /dev/dri`
tecleado por QMP responde card0 (teclado + PATH OK).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>