Dos bugs High encabezaban el registro de auditoría en vivo de sh0 la mañana del 2026-09-10. Por la
tarde ambos estaban cerrados, y cerrados de la única forma que este proyecto acepta: con una
observación fechada en una máquina real, antes y después, mismas aplicaciones, misma sonda. Esto es
lo que hizo falta.
Bug uno: un autoescalador que nunca decidía
El autoescalador de sh0 se despierta cada 30 segundos, lee los dos últimos minutos de muestras de CPU y memoria de cada aplicación con una política de escalado, y escala hacia arriba o hacia abajo. En la máquina de demostración llevaba semanas funcionando. Nunca había escalado nada.
La tabla de métricas guarda recorded_at mediante un valor por defecto de columna:
sqlrecorded_at TEXT DEFAULT (datetime('now')) -- 2026-09-10 00:43:04El autoescalador construía el límite de su ventana en Rust:
rustlet since = (Utc::now() - Duration::seconds(120)).to_rfc3339(); // 2026-09-10T00:41:28+00:00Luego la consulta decía recorded_at >= ?. La columna es TEXT, así que SQLite compara cadenas. En
el índice 10, las filas almacenadas llevan un espacio (0x20) y el límite lleva una T (0x54).
Todas las filas del día quedan ordenadas por debajo del límite. Cero filas, siempre, en todas las
aplicaciones. should_scale_up y should_scale_down eran ambos falsos de forma permanente.
Lo curioso: el campo de al lado, last_scale_at, era RFC3339 en ambos lados y funcionaba
perfectamente. Así fue como el periodo de enfriamiento se había demostrado en vivo el día anterior
mientras aquello que debía enfriar no se había disparado ni una sola vez.
La corrección no es una migración, a propósito. Todas las máquinas instaladas escriben la columna mediante el valor por defecto de SQL, así que es el límite el que pasa a adoptar la forma almacenada, a través de un único punto de construcción:
rustpub fn bound_seconds_ago(secs: i64) -> String {
(Utc::now() - Duration::seconds(secs)).format("%Y-%m-%d %H:%M:%S").to_string()
}La prueba unitaria inserta una fila mediante el valor por defecto y la vuelve a leer con ese
límite. También fija el defecto: un límite formado por los mismos dígitos con una T en lugar del
espacio no debe encontrar nada. El primer borrador de esa aserción usaba now - 120s y habría
fallado de forma intermitente durante dos minutos después de cada medianoche UTC, porque la parte de
la fecha cambia primero. La auditoría adversa lo detectó.
Bug dos: cincuenta segundos de 502 en cada redespliegue
El segundo bug ya se había «corregido» una vez. La ruta pública *.sh0.app pasa por un pequeño
proxy inverso dentro de sh0 que guarda en caché domain -> ip:port durante 60 segundos. Un
redespliegue sustituye el contenedor, así que la IP cambia, así que hay que purgar la caché. La
corrección del 6 de septiembre añadió una purga. La repetición del 9 de septiembre midió exactamente
la misma caída de 50 segundos.
El diagnóstico de esa repetición era correcto y trataba del orden, no de las claves. La purga se
ejecutaba dentro de route_container, que se ejecuta antes de detener el contenedor antiguo y
antes de que finalize_app escriba el nuevo container_id en la base de datos. Cualquier petición
que llegara entre medias volvía a resolver a través de la base de datos, encontraba el contenedor
antiguo y volvía a guardar en caché su upstream condenado con un TTL nuevo de 60 segundos. La purga
solo podía funcionar si nadie visitaba la aplicación durante el despliegue, que es el único caso en
que el bug no tenía víctima.
Leer el código añadió un detalle que la corrección del día 6 había pasado por alto: la ruta git, la
que sigue cada despliegue por git push, nunca llamaba a route_container. Enrutaba en línea.
Nunca había purgado nada.
La corrección mueve la escritura en base de datos antes de la purga, y ambas antes de la detención,
en las cinco rutas de despliegue, y una prueba de orden de código fuente lee pipeline.rs y rechaza
cualquier ruta que lo invierta. El proxy recibe una defensa para los reemplazos que el pipeline
nunca ve: un upstream en caché que rechaza la conexión se expulsa y se vuelve a resolver una vez;
un upstream que no respondió nunca se guarda en caché; un timeout nunca se reintenta, porque un POST
que agotó el tiempo puede haberse recibido.
La prueba, y la aplicación que se negaba a reproducir el bug
La regla aquí es que «corregido en el código» no es un estado. Una ficha se cierra con una observación, con la orden y su salida, o sigue abierta.
La mañana se dedicó a las líneas base en la máquina con v1.7.1. Para el bug del proxy, el primer
intento usó traefik/whoami: treinta sondas, treinta 200, ningún bug. El mismo binario que había
usado la repetición el día anterior. La diferencia era el contenedor: whoami gestiona SIGTERM al
instante, así que la ventana entre la purga y la muerte duraba milisegundos y una sonda cada tres
segundos nunca caía dentro. La aplicación Go de la repetición ignoraba SIGTERM y docker stop
esperaba sus treinta segundos completos. El bug necesita un contenedor que muera despacio. Con el
repositorio exacto del día 9, la línea base dio 000, 000, 502, 502, 502 de +12s a +55s. Un
http.server de Python ejecutándose como PID 1 dio dos 000. Esas son las dos aplicaciones sobre
las que se midió el después.
Para el autoescalador: tres réplicas de nginx en reposo, una política de 1 a 3 y una vigilancia
cada 30 segundos. Con v1.7.1: 36 muestras dentro de la ventana almacenada, tres réplicas durante
cuatro minutos y medio, cero decisiones.
Luego la release candidate. El agente de verificación ejecutó la batería completa (975 pruebas), se subió la etiqueta, GitHub tardó dos horas en ponerse con la build, y la instalación fijada aterrizó a las 09:06:56 UTC.
09:07:01 INFO Autoscaling down current_replicas=3 target_replicas=2
09:08:31 INFO Autoscaling down current_replicas=2 target_replicas=1Primer tick después del reinicio. Las sondas del proxy: treinta 200 en la ruta git, treinta 200
en la ruta Dockerfile y, para la defensa, docker stop dio un 404 en cinco segundos y
docker start dio 200 en tres milisegundos sin esperar al TTL.
Lo que la campaña encontró sin que nadie lo buscara
La contraprueba del autoescalador debía ser el escalado hacia arriba bajo carga. Cuarenta bucles
curl en paralelo durante 150 segundos sacaron 106 MB del contenedor. La métrica de CPU se quedó en
0.0 en las 22 muestras. El escalado hacia abajo funciona porque cero siempre está por debajo del
umbral. El escalado hacia arriba por CPU no puede dispararse nunca. Eso es una nueva ficha Medium,
con el archivo que hay que mirar y la observación que la cerrará, y no reabre la que se acaba de
demostrar: la prueba debida era el descenso hasta min_replicas, y eso ocurrió.
La mecánica que hizo que bastara un día
- La sesión de decisión de la mañana contrastó su propio prompt con el código antes de creérselo y
- encontró seis desviaciones, una de las cuales era que el prompt clasificaba como deuda aceptable
- un bug que bloqueaba la toolchain.
- La auditoría adversa se ejecutó antes del commit, en un agente de solo lectura, y devolvió cinco
- correcciones. Cuatro se aplicaron antes de que el compilador llegara a ejecutarse.
- La verificación se ejecutó en un agente en segundo plano cuya salida nunca entró en el contexto
- principal.
- La versión se incrementó antes de la etiqueta de la RC, porque la RC anterior había mostrado la
- cadena de versión antigua en la máquina y nadie sabía qué se estaba ejecutando.
- La máquina volvió a cero aplicaciones, y los logs de línea base están en el repositorio junto a
- las fichas.
El registro pasó de 22 abiertas a 21. Queda un High, bloqueado por una cuestión de hardware que
corresponde al CEO. El reloj del lanzamiento todavía no ha empezado a correr.