Resumen
Durante la validación F2 del build 2.14.0, se detectaron dos bugs críticos en el Lanzador (bootstrap) del Agente:
- Bug bootstrap:
ModuleNotFoundError: No module named 'app_main'en el.exe - Bug updater: Race condition en
_apply_or_defer()deja swap diferido colgado para siempre
Ambos fueron cazados en vivo y reparados. El build se retiró; 2.13.6 restaurado como fallback.
Bug 1: FrozenImporter bloquea importación de app_main.py
Síntoma
ModuleNotFoundError: No module named 'app_main'
en el .exe compilado con PyInstaller (no repite en dev).
Causa raíz
PyInstaller instala FrozenImporter en sys.meta_path con prioridad máxima sobre los finders de sys.path. El archivo app_main.py viaja como --add-data (data cruda en agent_modules/), no como módulo congelado, así que:
- FrozenImporter lo busca en su índice congelado → NO LO ENCUENTRA
- Los finders normales nunca se ejecutan → resolución falla
- Error explosivo al startup
Fix: importación por ruta explícita
Función nueva _import_app_main(source_dir) en terminal/agent/main.py:
- Carga
app_main.pyusandoimportlib.util.spec_from_file_location() - Registra el módulo en
sys.modules["app_main"]→ las importaciones posteriores (from app_main import ...en routes/) resuelven al mismo módulo - Si el fichero está ausente →
ModuleNotFoundErrorcon traza clara (“empaquetado incompleto”) - Si ya está en
sys.modules→ devuelve el existente (reutilización en dev/tests)
Código: terminal/agent/main.py#L165–L187
Tests añadidos
- test_carga_por_ruta_y_registra_en_sys_modules() # módulo cargado e indexado
- test_ya_en_sys_modules_no_recarga() # caché + no duplicación
- test_fichero_ausente_error_claro_con_traza() # error = bootstrap fallido
Bug 2: Swap diferido colgado en _apply_or_defer()
Síntoma
Build 2.14.0 quedó en estado “swap diferido” indefinidamente (casually detectado por Edu a las 14:23 del 07-07). El Agente no falló, pero el actualizador nunca terminó.
Causa raíz
En terminal/agent/core/updater.py, función _apply_or_defer():
if _is_idle():
_apply_swap()
return # ← INCOMPLETO
# esperando...
while True:
await asyncio.sleep(_IDLE_RETRY_SECS)
if _is_idle():
# reintenta...
Escenario de fallo:
- Agente está en reposo (preparándose para el sueño de 240s)
_apply_or_defer()detecta que NO está “verdaderamente ocioso” (sesiones aún activas, estado transitorio)- Aplica swap pero descarta/difiere →
NEW_EXEpersiste en disco - Retorna (línea 446)
- El bucle
while Truenunca corre porque ya pasó la ramaif _is_idle() - Agente entra en sueño, deja
NEW_EXEhuérfano
Nadie limpia el staged; next boot asume aplicación pendiente pero nada ocurre.
Fix: fall-through al bucle idle
if _is_idle():
_apply_swap()
if not NEW_EXE.exists():
return # aplicado por otra vía o descartado
# Si llegamos aquí: swap diferido con Agente ocioso.
# Sin este fall-through el bucle de abajo no se alcanzaba.
logger.info("[Updater] Update preparado; esperando...")
while True:
# Garantizado que se ejecuta si el swap se diferó
Lógica mejorada:
- Si
_apply_swap()tiene éxito →NEW_EXEdesaparece (os._exit o reboot) → early return - Si
NEW_EXEaún existe DESPUÉS de_apply_swap()→ fue diferido → continúa a bucle idle - El bucle idle reintenta cada
_IDLE_RETRY_SECShasta aplicarse
Comentario explicativo en el código
# Diferido con el Agente OCIOSO (reposo aún madurando) → sin este fall-through
# el staged quedaba esperando PARA SIEMPRE hasta el próximo arranque (cazado
# en vivo el 07-07-2026: el 2.14.0 de Edu se quedó en "swap diferido" a las
# 14:23 y nadie lo recogió...)
Impacto: El build retirado no tenía este control; 2.13.6 sí lo tenía.
Validación y rollback
| Fecha/Hora | Evento |
|---|---|
| 07-07 14:00 | Build 2.14.0 emitido y desplegado a sidecar de validación F2 |
| 07-07 14:23 | Edu reporta: swap diferido, Agente aparentemente sano, pero actualización colgada |
| 07-07 ~14:30 | Análisis: doble bug identificado (bootstrap + updater) |
| 07-07 ~14:45 | Build 2.14.0 retirado del server (sidecar rollback a 2.13.6) |
| Después | Fixes implementados; tests añadidos; recompilación para revalidación F2 |
Tests de regresión
Nuevos en tests/agent/test_agent_launcher.py:
class TestImportAppMain:
"""Regresión del bug del primer build 2.14.0"""
def test_carga_por_ruta_y_registra_en_sys_modules()
# Verifica: fichero → módulo en sys.modules
def test_ya_en_sys_modules_no_recarga()
# Verifica: segunda invocación no recarga (caché)
def test_fichero_ausente_error_claro_con_traza()
# Verifica: falta de archivo = excepción clara + log
Lecciones aprendidas
- PyInstaller +
--add-data: Los ficheros data NO se congelan; necesitan carga explícita si deben importarse como módulos. - Race conditions en bucles de sueño: Verificar que el bucle retry se alcanza incluso si la rama de “ocioso” no aplica directamente.
- Validación F2 crítica: Los bugs de empaquetado binario NO aparecen en dev. Validar con el
.exereal antes de release.
Véase también
- [[entity—terminal—service—updater-swap-watchdog]]