Commit Graph
235 Commits
Author SHA1 Message Date
sergioandClaude Opus 5 5295aa35c6 gnome: la cima ARRANCA en QEMU — muere en meta_launcher_new, causa localizada
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>
2026-07-28 21:47:11 -04:00
sergioandClaude Opus 5 f6734d6ce6 gnome: hydrate-gnome.sh — el cierre de runtime de la cima CIERRA 108/108
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>
2026-07-28 21:06:55 -04:00
sergioandClaude Opus 5 5aec112629 gnome onda 3: cierra las 3 fronteras de gnome-desktop + el latido regenera el grafo GNOME
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>
2026-07-27 19:14:59 -04:00
sergioandClaude Opus 4.8 da26b3a280 gnome onda-1: cola aislada incoming-gnome-onda1 para el worker
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>
2026-07-24 23:31:27 -04:00
sergioandClaude Opus 4.8 afd867ea51 yupana: drenar reckona honesto un perfil sin cierre (guardián GNOME)
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>
2026-07-24 14:23:12 -04:00
sergioandClaude Opus 4.8 9a028ba04f gnome: SPIDERMONKEY (mozjs 128) SELLA en el hub — keystone caído
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>
2026-07-24 14:17:13 -04:00
sergioandClaude Opus 4.8 d33fc94fc5 gnome: enciende la CAPA GRAFO del yupana-gnome
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>
2026-07-23 16:05:40 -04:00
sergioandClaude Opus 4.8 0b333b4c9e gnome: integra el frente a yupana — perfil escritorio-gnome + reckoning honesto
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>
2026-07-23 15:58:23 -04:00
sergioandClaude Opus 4.8 224b15f231 gnome: añade seed-gnome.py + los mapas del subtree (faltaron en 72de19c)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:50:17 -04:00
sergioandClaude Opus 4.8 b98995b7b4 deadman: NO contar farm-worker-loop como trabajo (3er bug del dead-man)
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>
2026-07-23 14:14:16 -04:00
sergioandClaude Opus 4.8 2bd1944173 deadman: --puede-borrar sourcea /etc/hammer-deadman.env (o daba falso-negativo)
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>
2026-07-23 03:48:41 -04:00
sergioandClaude Opus 4.8 3de75a5229 granja: el dead-man del worker se mata SOLO (sin hub) — 3 bugs + gioser doble-blindado
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>
2026-07-23 03:23:42 -04:00
sergioandClaude Opus 4.8 be06314336 granja: el reaper dropea de .fleet los workers que ya no existen en hcloud
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>
2026-07-23 03:08:13 -04:00
sergioandClaude Opus 4.8 1203f86cda granja: reaper HUB-SIDE — la garantía "vps idle inadmisible" deja de depender del worker
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>
2026-07-23 03:05:12 -04:00
sergioandClaude Opus 4.8 8c10c0a908 yupana: modelo EXTRA-GRAFO — preflight aprende los patrones de hueco de entorno
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>
2026-07-22 21:42:42 -04:00
sergioandClaude Opus 4.8 6a0dc40a05 sandbox: el merge de deps cae en el store (mismo fs), no en la raíz — desbloquea kio
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>
2026-07-22 21:06:10 -04:00
sergioandClaude Opus 4.8 85b5a9bc46 yupana: instrumentar duración de build en el worker → camino crítico PESADO
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>
2026-07-22 20:25:09 -04:00
sergioandClaude Opus 4.8 4f609080df yupana: los 2 "bugs" de duplicados eran falsos positivos MÍOS — detector afinado, 0 reales
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>
2026-07-22 20:14:10 -04:00
sergioandClaude Opus 4.8 619a30187c yupana: keystones — el camino crítico real, con la matemática correcta (AND, no OR)
`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>
2026-07-22 20:03:56 -04:00
sergioandClaude Opus 4.8 0f3a1dc394 yupana: invariante (c) CANÓNICO — detector de duplicados por evidencia de fuente
`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>
2026-07-22 19:59:20 -04:00
sergioandClaude Opus 4.8 e4a0237b6d yupana: rename khipu→yupana — el registro es el khipu, la yupana es el ábaco que reckona sobre él
`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>
2026-07-22 19:53:53 -04:00
sergioandClaude Opus 4.8 4d0a909bbd khipu: guardián de regresión del radio cruzado (la cicatriz de libdrm)
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>
2026-07-22 18:45:37 -04:00
sergioandClaude Opus 4.8 8da6ef6122 khipu: el motor de dependencias inversas que cruza TODAS las colas (y arregla la mentira de perfiles)
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>
2026-07-22 18:43:40 -04:00
sergioandClaude Opus 4.8 9b67268a65 triaje: los 170 candidatos de frontera clasificados, y la cadena se ejerce entera
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>
2026-07-22 18:30:19 -04:00
sergioandClaude Opus 4.8 ec28a9b394 catálogo objetivo P5: tandas a archivo, y el triaje que cierra el lazo
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>
2026-07-22 18:20:30 -04:00
sergioandClaude Opus 4.8 25e3878331 farm: JOBS=1 en el worker-loop mientras el ADR 0012 esté sin decidir
`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>
2026-07-22 18:15:38 -04:00
sergioandClaude Opus 4.8 b08e582c89 catálogo objetivo P4: la cola de la granja pasa a ser derivada del grafo
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>
2026-07-22 18:14:37 -04:00
sergioandClaude Opus 4.8 0093c1f19c catálogo objetivo P3: sembrador de aristas desde nixpkgs, calibrado contra la verdad
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>
2026-07-22 18:01:08 -04:00
sergioandClaude Opus 4.8 a27d9a94a4 catálogo objetivo P2: estado wanted + membresía de perfil en el grafo
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>
2026-07-22 17:43:40 -04:00
sergioandClaude Opus 4.8 c5ae42b92e espejo: push a gitea + GitHub privado, y script para reponerlo tras un clon
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>
2026-07-22 17:41:16 -04:00
sergioandClaude Opus 4.8 3a64b603d7 catálogo objetivo P1: manifiesto de perfiles — el set de la distro deja de ser un string de shell
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>
2026-07-22 17:41:01 -04:00
sergioandClaude Opus 4.8 142c417d36 farm: que el loop DIGA que está esperando el lock, en vez de parecer que trabaja
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>
2026-07-22 17:21:24 -04:00
sergioandClaude Opus 4.8 1b436a717f farm: lock compartido entre campana-deuda y el worker-loop
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>
2026-07-22 17:06:42 -04:00
sergioandClaude Opus 4.8 ebd8e7f929 farm: anclar el store con bind-mount, no symlink — el ln -s rompía toda receta con zig_version
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>
2026-07-22 16:26:52 -04:00
sergioandClaude Opus 4.8 f3206d7520 latido: colgar el latido de la sesión, no del init (+ lock anti-solapamiento)
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>
2026-07-22 16:21:27 -04:00
sergioandClaude Opus 4.8 e09a48a69e farm: tandas.sh — encadenar campañas en orden, sin solaparse
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>
2026-07-21 22:51:11 -04:00
sergioandClaude Opus 4.8 a8bb83add5 why-differs-barrido: no llamar "no-reproducción" a lo que no está probado
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>
2026-07-21 22:42:32 -04:00
sergioandClaude Opus 4.8 895401b479 cierre #2 del SDD 17: hammer why-differs — el diffoscope propio
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>
2026-07-21 22:35:03 -04:00
sergioandClaude Opus 4.8 6b38b46293 farm: campana-deuda.sh — el HUB dicta la lista, el worker sólo muele
`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>
2026-07-21 22:10:31 -04:00
sergioandClaude Opus 4.8 c21602b707 farm: fix — farm-up tiraba el store HORNEADO de la golden (rm -rf) y el worker medía media distro como deuda
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>
2026-07-21 22:10:31 -04:00
sergioandClaude Opus 4.8 081fdd8a34 kde/metal: fix — el prompt nacía en el SERIAL; getty en tty1 (por eso el USB "no abría")
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>
2026-07-21 21:32:06 -04:00
sergioandClaude Opus 4.8 0a3a00eb6e kde/metal: fix — la imagen dual usa linux-metal-dual (no linux-generic, que no bootea por EFI-stub)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:52:31 -04:00
sergioandClaude Opus 4.8 746d2dedd1 kde/metal: andamiaje escritorio dual-GPU (Intel i915 + NVIDIA nouveau) por software
Objetivo: USB "live" que arranca el escritorio KDE en metal y aguanta ambas máquinas
(TigerLake/iris y Pascal/nouveau), render por software (mesa-llvmpipe soberano) sobre
el KMS del kernel — uniforme en cualquier GPU, reusa los fixes validados en QEMU.

- recipes/linux-metal-dual.toml: = linux-generic (dual-GPU i915+nouveau+radeon) + el
  CMDLINE de pivote de linux-metal (initrd=/initramfs.cpio.gz rdinit=/init) para bootear
  por EFI-stub directo desde el ESP (root en disco, no RAM). Ni linux-generic (sin pivote)
  ni linux-metal (nouveau OFF) servían solos.
- scripts/kde/metal-desktop-image-dual.sh: arma la imagen — base metal + KDE + inyección
  mesa-llvmpipe/libLLVM/musl + firmware nvidia gp106 (nouveau modeset Pascal) + kernel
  linux-metal-dual.
- scripts/kde/plasma-start-metal-sw.sh: launcher software-GL para metal, detección de GPU
  real (i915/nouveau/amd), lanzamiento MANUAL (bringup seguro), con los fixes de la sesión
  QEMU (GBM_ALWAYS_SOFTWARE, USE_MODIFIERS=0, KWIN_COMPOSE=Q, FORCE_SW_CURSOR, cursor/iconos
  breeze, D-Bus sesión+sistema).

Pendiente: build del kernel (~60min, en curso) → rebuild imagen → validar OVMF → quemar USB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:32:46 -04:00
sergioandClaude Opus 4.8 44d50a76b5 kde/qemu: KWIN_FORCE_SW_CURSOR=1 — cursor visible y estable
El tema de cursor ya cargaba (sin "Failed to load cursor theme") pero el cursor
seguía apareciendo/desapareciendo: kwin usaba el plano de cursor por HARDWARE del
DRM, que en virtio-gpu con render software no se presenta bien. KWIN_FORCE_SW_CURSOR=1
⇒ kwin compone el cursor dentro del framebuffer principal (software) ⇒ visible y
estable. Verificado por screendump: la flecha breeze aparece compuesta en el scanout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:36:28 -04:00
sergioandClaude Opus 4.8 abf4e25c8f kde/qemu: tema de cursor breeze — mata "Failed to load cursor theme"
kwin buscaba el tema "default" (inexistente) ⇒ cursor invisible. El rootfs trae
breeze_cursors (115 cursores). Fix: XCURSOR_THEME=breeze_cursors + XCURSOR_PATH +
kcminputrc [Mouse] cursorTheme. El error desapareció del log (0 ocurrencias).

Estado final verificado por screendump: escritorio Plasma 6 completo (wallpaper +
panel con launcher/pager/bandeja + reloj), estable (139=0; el run previo aguantó
7.5 min a +450s con kwin=1 plasmashell=1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:30:32 -04:00
sergioandClaude Opus 4.8 d724db6556 kde/qemu: -vga none — el escritorio por fin SE VE (no más TianoCore congelado)
El escritorio booteaba y kwin pintaba por dentro (serial: kwin vivo), pero el scanout
REAL (verificado por `screendump` del monitor QEMU → PNG) seguía congelado en el logo
TianoCore. Causa: sin -vga none, QEMU añade una VGA estándar por defecto ADEMÁS del
virtio-gpu-pci. OVMF pinta su GOP en la VGA estándar ⇒ el kernel la envuelve con
simpledrm (card1) y ESE es el scanout que se muestra; kwin pinta en virtio-gpu (card0),
otro device que no se ve. Como son devices distintos, virtio no expulsa simpledrm ⇒
pantalla clavada en el frame EFI. (Con VARS reusados el timing lo tapaba; al resetear
los VARS quedó expuesto.)

Fix: -vga none ⇒ virtio-gpu-pci es el único display ⇒ OVMF pinta ahí ⇒ virtio-gpu-DRM
expulsa simpledrm (mismo device) ⇒ un solo card0, y el modeset de kwin toma la pantalla.
Verificado por screendump: se ve el wallpaper de Plasma 6 (1592x960), no TianoCore.

Herramienta nueva: -monitor unix socket en run-qemu-desktop.sh ⇒ `screendump` captura
el framebuffer REAL del guest (oráculo independiente de la ventana gtk, que puede
congelarse si mirada reinicia su Xwayland).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:22:07 -04:00
sergioandClaude Opus 4.8 82cbdb7e54 kde/qemu: KWIN_COMPOSE=Q (QPainter) — esquiva el segfault del rasterizador llvmpipe
El "pinta y vuelve a negro" NO era el output config: era kwin SEGFAULTEANDO (139) a
los segundos de pintar y respawneando. Core dump (core.llvmpipe-0, 176MB) + gdb del
host: el crash está en un hilo rasterizador de llvmpipe —
  #0 util_fill_rect  #1 util_fill_box  #2 lp_rast_clear_color  #3 rasterize_scene
  #4 thread_function (llvmpipe worker)
llvmpipe peta al limpiar el color buffer (bug de stride/geometría o vectorización del
fill en este entorno virtio+kms_swrast).

Fix: KWIN_COMPOSE=Q ⇒ kwin compone con QPainter (raster de Qt directo al buffer, SIN
armar escenas llvmpipe) ⇒ nunca entra a lp_rast_clear_color. (El segfault viejo con =Q
era el de gbm-init, ya resuelto por GBM_ALWAYS_SOFTWARE; llvmpipe sigue disponible para
gbm/EGL.) Verificado: QPainter compositing initialized, 139=0, kwin vivo a +15s/+30s.

+ STATUS monitor arreglado (busybox no tiene pgrep -c ⇒ pgrep|wc -l) con timestamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:09:38 -04:00
sergioandClaude Opus 4.8 2f99446ca1 kde/qemu: un solo output virtio (KWIN_DRM_DEVICES) — mata el negro-tras-pintar
Tras arreglar el FB (modifiers), el escritorio pintaba con plasmashell y volvía a
negro. Causa: kwin veía DOS outputs — virtio-gpu ("QEMU Monitor") Y el efifb/simpledrm
("Unknown-1", framebuffer EFI read-only que no fue expulsado al cargar virtio-gpu).
El conflicto entre ambos rompía el import/destroy de buffers EGL (spam
eglDestroyImageKHR EGL_BAD_PARAMETER) y tiraba la conexión de plasmashell
("error in client communication") ⇒ negro.

Fix: plasma-start detecta el card cuyo driver es virtio_gpu (robusto al numerado
card0/card1 que cambia entre boots) y lo pasa por KWIN_DRM_DEVICES ⇒ kwin ignora el
efifb. Resultado: un solo output, atomic modeset, y TODOS los errores correlacionados
con el negro a 0 (client-comm, eglDestroyImage, FB fail, output disabled). kwin carga
efectos normalmente.

+ monitor de estado cada 15s al serial (kwin/plasmashell vivos) para diagnóstico.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:01:16 -04:00
sergioandClaude Opus 4.8 6c0ef0867d kde/qemu: KWIN_DRM_USE_MODIFIERS=0 — mata la pantalla negra (FB rechazado por virtio)
Con el segfault ya resuelto, el escritorio salía NEGRO: kwin encontraba el conector
(1280x800) pero "Failed to create a framebuffer: Invalid argument" ⇒ "Failed to find
a working output layer" ⇒ sin scanout. Se veía el wallpaper un instante (el modeset
inicial usa un buffer dumb sin modifiers) y luego negro (los buffers del compositor
van por drmModeAddFB2WithModifiers).

virtio-gpu ADVIERTE el CAP de modifiers pero rechaza el modifier explícito (aún
LINEAR=0) con EINVAL. Fix: KWIN_DRM_USE_MODIFIERS=0 ⇒ kwin usa drmModeAddFB2 plano
(modifier implícito) que virtio sí acepta. "Failed to create a framebuffer" pasó de
2526 a 0; input Qt fluyendo; sin crash.

También: quitado KWIN_DRM_NO_AMS (era corazonada del segfault; atomic es el path
sólido en virtio) y arreglado el stream vivo del kwin.log al serial (busybox sed no
soporta -u ⇒ tail -f directo, sin sed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:52:59 -04:00
sergioandClaude Opus 4.8 6e5bc48c58 kde/qemu: system D-Bus — mata el spam udisks2 y completa el escritorio
plasmashell/solid spameaba "kf.solid.backends.udisks2: Not connected to D-Bus
server" en cada poll: sólo había bus de sesión, no de sistema. Ahora plasma-start
levanta dbus-daemon --system (socket estándar /run/dbus/system_bus_socket).

- qemu-desktop-image.sh: siembra el usuario messagebus:81 en passwd/group
  (system.conf hace setuid a él; el passwd base no lo traía). Rompe el hardlink
  read-only con `mv` de un temp, como el patch del seed.
- plasma-start-qemu.sh: arranca el system bus antes del de sesión + copia
  machine-id a /var/lib/dbus.

No hay udisksd/upowerd reales en el rootfs ⇒ sin discos/batería, pero el bus
existe y solid deja de errorear. Verificado: "Not connected" pasó de spam
continuo a 0; system bus ON; kwin+plasmashell+KSplash+kioworker vivos; 139=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:43:34 -04:00