Inicio / Artículos / Leases de dispositivos en Postgres: que los agentes paralelos dejen de pelearse por los emuladores

Leases de dispositivos en Postgres: que los agentes paralelos dejen de pelearse por los emuladores

Cómo una sola tabla de Postgres y un script bash corto evitan que sesiones paralelas de Claude Code choquen en emuladores y simuladores, más los tres bugs que detectaron los tests.

1293 palabras

Un simple pkill -f maestro se llevó por delante también un emulador de Android, porque el dispositivo virtual se llamaba maestro_e2e. Fue una de tres colisiones silenciosas en una sola sesión. La solución: una tabla de Postgres, un archivo bash de 243 líneas y tres líneas añadidas en cada runner de tests.

El commit que introdujo el registro explica el contexto. Había nueve worktrees y las sesiones corrían a la vez, pero cada runner se comportaba como si estuviera solo. Las tres colisiones:

  • pkill -f "maestro" también tumbaba el emulador de Android, porque el nombre del AVD maestro_e2e coincidía.
  • Los simuladores se arrancaban, borraban y eliminaban sin comprobar si otra sesión estaba ejecutando tests en ellos.
  • Cuando dos sesiones comparten un dispositivo, el fallo parece un bug del producto. Es la colisión cara, porque nada en el síntoma apunta a la causa real.

Hoy el repo tiene once worktrees. Cada sesión de Claude Code recibe el suyo, muchas están activas a la vez y todas tiran de los mismos dos emuladores de Android, un Galaxy Z Fold real a través del Remote Test Lab de Samsung y dos simuladores de iOS. No había ningún mecanismo que las separara.

Una tabla, y la clave primaria es el lock

El registro es una única tabla de Postgres en una base de datos propia llamada yarina_devtools. No es un esquema dentro de la base de la app, y es a propósito: debe quedar fuera de las migraciones, dumps y backups de la app, y borrarla nunca debería dar miedo.

CREATE TABLE IF NOT EXISTS device_lease (
  device_id   text PRIMARY KEY,
  platform    text NOT NULL CHECK (platform IN ('ios', 'android')),
  worktree    text NOT NULL,
  session_id  text NOT NULL,
  holder_pid  integer NOT NULL,
  purpose     text NOT NULL DEFAULT '',
  acquired_at timestamptz NOT NULL DEFAULT now(),
  expires_at  timestamptz NOT NULL
);

La clave primaria hace todo el trabajo de atomicidad. Un claim es una sola sentencia: INSERT … ON CONFLICT (device_id) DO UPDATE … WHERE la fila actual pertenece a la misma sesión o ha expirado, y luego RETURNING session_id. Postgres serializa los claims concurrentes sobre esa clave, así que entre N competidores gana exactamente uno. Si el diseño hubiera usado una tabla de historial con un flag active, «¿está libre este dispositivo?» sería una consulta aparte, y ahí es donde se colaría una carrera entre aspirantes.

Antes de tocar un dispositivo, cada runner hace las mismas tres cosas:

source "$REPO_ROOT/e2e/scripts/device-lease.sh"
trap 'lease_release_all' EXIT INT TERM
lease_claim "$DEVICE" android "android $SUITE" || exit 1

Un claim rechazado muestra quién tiene el dispositivo (worktree, sesión, propósito, desde cuándo) junto con el comando para liberarlo. Para comprobar si sigue vivo, el script ejecuta kill -0 contra el PID del titular. Postgres no tiene forma de saber si un proceso está corriendo, pero aquí todo está en una sola máquina, así que puede preguntar quien llama. Un TTL de 4 horas cubre el caso de un PID reutilizado. La tabla se crea en el primer claim; crearla al inicio de la sesión se probó y se descartó, porque la mayoría de las sesiones nunca usan un dispositivo.

Tres bugs que encontraron los tests

El uso real nunca los habría destapado.

psql imprime una etiqueta de comando. Un claim rechazado devuelve cero filas, y eso es el rechazo. Pero sin -q, psql escribía INSERT 0 0 en stdout, así que la comprobación de salida vacía veía texto y cada test de contención se leía como un éxito. Lo detectó el test de contención; en el día a día habría pasado desapercibido.

Comprobar si el titular está vivo antes del claim provoca carreras. La primera versión buscaba el PID del titular en una consulta y pasaba el resultado al insert. Ocho aspirantes simultáneos no veían ningún titular, concluían que el dispositivo estaba libre, y los ocho «ganaban». Ahora el orden es el inverso: primero se intenta el claim, sin quitárselo nunca a un titular vivo, y solo tras un rechazo se mira al titular y se borra con un compare-and-swap sobre (holder_pid, acquired_at), y se reintenta una vez. De ocho, gana uno.

Un claim desde la CLI que guarda su propio PID muere en cuanto termina. Por eso los claims manuales guardan holder_pid 0, es decir, sin proceso propietario, y el TTL decide cuándo caducan.

Un runner añadió un cuarto problema. Con exec maestro … se sustituye el proceso del shell, el trap de EXIT no puede ejecutarse y el lease sigue ahí cuando termina la ejecución. Invoca maestro como un comando normal. El trap es imprescindible: si el lease solo se libera cuando todo va bien, el dispositivo se queda bloqueado tras las ejecuciones fallidas, que son justo las que más probablemente se relanzan al momento.

Fallar cerrado y decir dónde están los huecos

Si Postgres no está disponible, el runner se niega a continuar y explica por qué. Degradar en silencio a un no-op es peor que no tener registro, porque el operador cree que está protegido cuando no lo está. YARINA_DEVICES_OPTIONAL=1 es la forma documentada de seguir sin protección.

Llamar directamente a maestro test --device X se salta los leases por completo. Se valoró un shim envoltorio sobre maestro, simctl y adb, y se descartó por intrusivo y fácil de esquivar en cuanto algo invoca el binario real. Así que los runners son la frontera, y la regla está en CLAUDE.md, el único archivo que lee cada sesión.

Un lease no convierte en verdad una ejecución en verde

Compartir dispositivos obligó a dos comprobaciones más.

Verifica el build por hash. Tres ejecuciones en verde habían probado en realidad un APK instalado por otra sesión. La ejecución de reemplazo comparó el hash del APK del dispositivo (pm path, luego shasum) con el build local, una vez antes y otra después. Ahora es la regla fija para cualquier dispositivo compartido, porque un flujo que pasa no dice nada de un código que no está en el dispositivo.

Vigila la carga de la máquina. Código que pasaba con una carga cercana a 8 fallaba con una cercana a 20 por timeouts de reintento, sin ningún cambio. Dos simuladores y un emulador de Android juntos subieron el load average a 94 y tumbaron SpringBoard 16 veces, así que el runner de iOS ya no arranca por encima de un umbral de carga. Darle más RAM al emulador llevó el swap al 93 %, y el sistema operativo mató el emulador a mitad de una ejecución, otra cosa que parece flakiness.

Cómo aplicarlo en tu repo

  1. Usa una tabla con el ID del dispositivo como clave primaria y haz el claim con un único INSERT … ON CONFLICT … WHERE … RETURNING.
  2. Libera desde un trap en EXIT INT TERM y deja exec fuera del runner.
  3. Niégate a ejecutar cuando el almacén no esté disponible, con una sola vía de escape explícita.
  4. Haz un test de contención con N competidores antes de fiarte del lock, comprueba la salida vacía y pásale -q a psql.
  5. Antes de creerte una ejecución en verde en una máquina compartida, calcula el hash del APK del dispositivo y mira uptime.
  6. Escribe la regla donde la lea cada sesión, porque un registro que nadie conoce se esquiva por defecto.

Qué recordar

Lo esencial: los agentes paralelos necesitan un lock sobre los dispositivos físicos, porque dos sesiones en un mismo emulador producen fallos que pasan por bugs del producto. El lock va en la clave primaria, como una sola sentencia atómica sin paso de leer y luego escribir. Un test de contención en verde puede mentir: INSERT 0 0 contaba como éxito hasta que alguien lo comprobó. Falla cerrado y nombra al titular, porque un no-op silencioso es peor que no tener registro. Y, por último, un lease no es la última línea de defensa: haz hash del build, vigila la carga y luego confía en la ejecución.