Saltar al contenido principal
Volver al blogCopiar Markdown
·Actualizado ·10 min de lectura
·claude codeagentsiosxcodeverification

Claude Code en iOS: vio el Mac saturado y falló en el informe

Varios agentes de Claude Code en paralelo saturaron mi Mac con una app de iOS. Uno lo vio en un crash report; su resumen traía tres afirmaciones falsas →

Óscar Gallego

Ingeniero de software · Málaga

Un robot mira con una lupa el único informe resaltado de una pila de crash reports, con una nota de tres líneas tachadas en la otra mano
En este artículo

Respuesta corta: el domingo tenía varios agentes de Claude Code trabajando a la vez en Rydger, mi app de iOS, y el Mac, con 12 núcleos, llegó a una carga de 422. Un agente leyó una línea de un crash report y vio enseguida que la máquina estaba saturada; su explicación, que nadie ha confirmado del todo, fue que había demasiados arranques de simulador y builds a la vez. Después escribió un resumen con tres afirmaciones falsas, y casi lo convierto en este artículo.

Todo empezó con un crash report de MobileCal, el calendario de iOS, dentro del simulador. Cuando contamos después los de la carpeta, había 141, de dos días. Le pegué el de MobileCal a uno de los agentes y le pregunté qué pasaba.

El resumen que me dio después traía hasta título para el artículo: «Una carga de 422 y 74 procesos zombi». No había ningún zombi.

Una línea del informe de MobileCal ya decía que faltaba CPU

El informe era un 0x8BADF00D, el código del watchdog de iOS, que Apple pronuncia «ate bad food». MobileCal tenía 30 segundos para arrancar y no le dio tiempo.

Lo útil estaba arriba, en el motivo de terminación, bajo WatchdogCPUStatistics:

"Elapsed total CPU time (seconds): 360.720 (user 292.190, system 68.530), 100% CPU",
"Elapsed application CPU time (seconds): 1.802, 0% CPU"

En esos 30 segundos de reloj, MobileCal usó 1,8 segundos de CPU y el sistema entero, 360. Según la documentación de Apple, la primera cifra suma todos los procesos en todos los núcleos, y si la de la app es baja, la app estaba casi todo el rato parada esperando recursos del sistema. 360 segundos de CPU en 30 de reloj son casi 12 núcleos ocupados todo el rato, los 12 de mi M2 Max. El hilo que estaba en marcha cuando lo mataron seguía en dyld_sim cargando bibliotecas dinámicas: MobileCal ni siquiera llegó a main.

El agente lo vio y fue directo a mirar la máquina en vez del código. Lo primero que lanzó incluía uptime y la lista de simuladores arrancados:

11:53  up 13 days,  2:17, 2 users, load averages: 422.71 304.75 191.54

Había tres simuladores arrancados, al menos dos agentes trabajando y una suite de tests compilando en segundo plano. Tres de los cuatro procesos que más CPU consumían eran del runtime del simulador y llevaban entre 2 y 4 segundos vivos; el otro era ANECompilerService. Según su propio resumen, unos cinco minutos después el agente tenía su explicación: demasiados arranques de simulador y builds a la vez para 12 núcleos. Es la más probable, pero no medí cuánto aportaba cada cosa.

La próxima vez que me salgan crash reports del simulador, abriré primero los 0x8BADF00D y compararé la CPU de la app con la total. Si la total se acerca al tiempo de reloj multiplicado por los núcleos, el problema es el Mac. Es un solo caso, uno de 141 informes, así que lo tomo como primera comprobación y no como regla.

Los otros 140 informes no señalaban la causa

Otro agente los volvió a contar en el Mac ese mismo mediodía: 139 de los 141 venían de simuladores, de 20 simuladores distintos.

  • 94 Trace/BPT trap: 5, de daemons del simulador como intelligencetasksd (38) o AppIntentsLiveEntityService. Siguieron saliendo con la carga ya baja, así que no hace falta saturación para que salgan. Nadie ha mirado todavía por qué fallan; en el chat, el agente atribuyó los de intelligencetasksd a un bug del runtime de iOS 27 sin comprobarlo.
  • 44 XPC_EXIT_REASON_SIGTERM_TIMEOUT: daemons que no terminan a tiempo cuando se apaga el simulador. Que salgan más con la máquina saturada es una hipótesis razonable, pero no la he medido.
  • Un solo 0x8BADF00D, el de MobileCal.
  • Dos del propio Mac: un Bus error de upload-symbols, que nadie ha analizado, y otro de un multiplexor de terminal.

El script de capturas reiniciaba el simulador en cada idioma

En el post anterior sobre subagentes conté que cada worktree necesita su propio simulador. El agente que hacía las capturas para el App Store trabajaba en su propio worktree, pero usaba el simulador del checkout principal.

Su script, capture.sh, hacía esto en cada uno de los 12 idiomas: apagar el simulador, arrancarlo y lanzar xcodebuild test, que pasa por un build incremental en cada llamada. El comentario lo justificaba así: si se lanzaban tests de UI seguidos sin reiniciar, el runner no arrancaba y daba «Application failed preflight checks» (Busy). El idioma, en cambio, ya le llegaba a la app como argumento de lanzamiento (-AppleLanguages), así que ningún idioma necesitaba su propia compilación.

El agente lo cambió para compilar una vez con build-for-testing, ejecutar cada idioma con test-without-building y reiniciar solo antes de un reintento, por si vuelve el error del Busy. Lo importante no está comprobado: nadie ha lanzado todavía una tanda de 12 idiomas con el script nuevo mirando la carga, y del viejo no hay tiempos. Y otro problema sigue sin resolver: limitar cuántos simuladores arrancan a la vez en el Mac.

El resumen del agente tenía tres afirmaciones falsas

De paso, el agente encontró 74 procesos upload-symbols de Crashlytics. Los deja en segundo plano el script run de Firebase, con un &, casi en cada build. El más antiguo llevaba 10 días vivo. Hasta ahí, todo cierto. Pero el resumen que me dio en el chat decía además:

  1. Que eran procesos zombi. Estaban dormidos (estado S), al 0 % de CPU y con su proceso padre vivo: colgados. Y un proceso que no usa CPU no aporta carga.
  2. Que tras el arreglo la carga bajó a 3,6. Bajó sola. Hubo un repunte a 148 a las 12:03, cuando el agente volvió a lanzar la suite completa de tests, y a las 12:09 ya rondaba 8, unos 11 minutos antes de matar esos procesos. Fue bajando sola mientras terminaban los arranques y los builds.
  3. Que esperaban un dSYM que en Debug nunca llega. Los builds Debug de Rydger sí generan el dSYM. Sigo sin saber por qué se colgaban.

Las tres encajan con lo que esperarías leer: procesos que se acumulan suenan a zombis, y una carga que baja después de un arreglo parece obra del arreglo. Esas frases iban a ser el título y las lecciones del artículo. Otra lección decía que los tests de StoreKit fallaban con carga 400 y pasaban con carga 10. Fallaron en una ejecución de tres, y una de las que pasaron fue una suite completa con la carga en 148, medida justo al terminar.

Las 23 preguntas retiraron las tres frases

Antes de escribir le mandé 23 preguntas y una instrucción: pega la salida literal de cada comando, y si algo no se midió, dilo y no lo estimes. Volvió con un documento que empezaba por «Correcciones antes de empezar» y retiraba las tres frases. También corrigió el comentario que había dejado en el código de Rydger, que repetía lo del dSYM.

No es el mismo caso que el del verificador de agentes. Allí otro agente compara lo que se dice del código con el propio código, que sigue ahí al día siguiente. El estado de una máquina no sigue ahí. La carga de 422 ya no se puede volver a medir, y no hay ninguna captura del Monitor de Actividad. Si la salida de uptime y ps no se guarda mientras el incidente sigue en curso, el resumen del agente se queda como la única prueba.

Hasta el documento corregido contaba mal

El documento corregido ponía 92 informes SIGTRAP (84 + 8) y 41 de SIGTERM (27 + 14). Cuando otro agente los recontó, con un bucle que lee el campo indicator de cada .ips, salieron 94 y 44. La diferencia es pequeña, pero dice algo: las preguntas hicieron el documento más honesto con lo que no se midió, no más exacto con lo que sí. Y tuve suerte: solo hice esas preguntas porque iba a publicar un artículo.

Guarda la salida antes de pedirle el resumen

Mientras el incidente siga en curso, guarda la salida en un fichero del repo, porque es lo único que no se puede repetir. Con esto basta:

{ date; uptime; xcrun simctl list devices booted;
  ps -Ao pid,ppid,stat,pcpu,etime,command -r | head -40;
  ls -lt ~/Library/Logs/DiagnosticReports | head -50; } > incidente-$(date +%F-%H%M).txt

Después, cuando el agente te dé el resumen, mándale esto:

Para cada cifra y cada causa del resumen, cita la línea del fichero de
pruebas que la demuestra. Si algo no está ahí o es una deducción, márcalo como tal.

Y antes de darlo por bueno, cuenta una cifra con tu propio comando, o pídeselo a un agente que no haya escrito el resumen. Al segundo agente, el recuento le salió distinto.

Mejor todavía: que la guarde el runner

Roberto Gómez, desarrollador de iOS, me señaló en LinkedIn el punto débil del comando anterior: depende de que alguien se acuerde de lanzarlo mientras el incidente sigue vivo. Si se lo pides al agente en una skill, se lo puede saltar o llegar tarde. La propuesta es sacar la captura del agente y meterla en el script que lanza los builds: una foto de la máquina antes de empezar y otra cada vez que algo falle. Así la única prueba no depende del mismo agente que luego explica lo que pasó.

Es la versión buena, y la estoy aplicando en Rydger y en WealthSim. La idea cabe en un envoltorio de quince líneas:

#!/bin/bash
# Captura antes de la ejecución, y otra vez si falla o la matan
snap() {
  mkdir -p .incidents
  { echo "# $1 $(date '+%F %T') ${2:+exit=$2}"
    uptime; sysctl -n hw.ncpu
    xcrun simctl list devices booted
    ps -Ao pid,ppid,stat,pcpu,etime,command -r | head -40
    ls -lt ~/Library/Logs/DiagnosticReports | head -50
  } > ".incidents/$(date +%Y%m%d-%H%M%S)-$1.txt" 2>&1
}
snap start
"$@" & child=$!
trap 'kill -TERM $child; snap killed TERM; exit 143' TERM INT
wait $child; code=$?
[ $code -ne 0 ] && snap fail $code
exit $code

Lo usas delante del comando de siempre: ./run-with-capture.sh xcodebuild test -scheme MiApp -destination "id=$udid". Mete .incidents/ en el .gitignore. Tres detalles importan:

  • Devuelve el mismo código de salida que el comando, así que lo que dependa de él sigue funcionando. Lo he probado con una ejecución que acaba bien, una que falla con 3 y una que mato a mitad: devuelve 0, 3 y 143.
  • La captura del fallo va antes de cualquier limpieza o reintento. Si tu script apaga el simulador o reintenta el test, la foto tiene que salir antes, o ya no verás lo que había.
  • Captura también cuando lo matan. Un timeout o un Ctrl-C son justo los casos en que la máquina estaba peor.

En una versión más completa conviene listar solo los crash reports posteriores al inicio de la ejecución y borrar las capturas antiguas para que la carpeta no crezca sin fin.

Esto no evita la saturación. Para eso, Roberto propone empezar con un solo build o una sola ejecución de tests a la vez, a través de un runner compartido, mientras el resto de agentes sigue leyendo y editando código, y subir ese límite solo después de medir. Lo que cambia con la captura es que, cuando le pidas el resumen al agente, el fichero de pruebas ya existe y nadie ha tenido que acordarse de guardarlo.

Fuentes

  • Apple: Addressing watchdog terminations, consultada el 1 de noviembre de 2026.
  • Caso propio: los informes .ips de ~/Library/Logs/DiagnosticReports/, la salida de uptime y ps del agente, y los scripts de Rydger. El repositorio es privado.

Lectura relacionada: subagentes o skills y el simulador compartido y el verificador que devolvió cuatro de cinco.


P.D. ¿Algún agente te ha dado un resumen convincente que luego no aguantaba las pruebas? Cuéntamelo en LinkedIn.

Última actualización:

Comparte este artículo

Artículos relacionados