April 21, 2026

Mensaje de usuario perdido cuando ejecución de latido produce HEARTBEAT_OK - Steered User Message Swallowed When Heartbeat Run Produces HEARTBEAT_OK

En el modo de cola de dirección, los mensajes de usuario inyectados en ejecuciones de latido se pierden cuando el agente produce HEARTBEAT_OK, debido a la lógica de supresión de respuesta a nivel de ejecución que trata toda la ejecución como un no-op de latido.

🔍 Síntomas

Manifestación principal

Cuando un usuario envía un mensaje en Telegram durante una ejecución activa de heartbeat en modo steer, el usuario no recibe respuesta alguna a pesar de que el agente genera una respuesta correcta.

Secuencia exacta de reproducción

  1. Configurar el modo cola como steer:
    # config.yaml
    messages:
      queue:
        mode: "steer"
    heartbeat:
      interval: 3s
    
  2. Esperar a que se active una ejecución de heartbeat (visible en los logs como HEARTBEAT_RUN)
  3. Enviar un mensaje de usuario dentro de la ventana de heartbeat
  4. Observar lo siguiente en los logs de la aplicación:
    [heartbeat] HEARTBEAT_RUN triggered at 2024-01-15T10:30:01.234Z
    [steer] Injecting user message "What's the weather?" into heartbeat run
    [agent] Processing run #42 (heartbeat + steered message)
    [agent] Run #42 produced responses:
      - HEARTBEAT_OK (heartbeat ack)
      - TEXT: "The weather is sunny, 72°F"
    [outbound] Discarding run #42 responses (HEARTBEAT_OK detected)
    [channel] Delivered: HEARTBEAT_OK ack only (no user message)
    
  5. El usuario no recibe nada—la respuesta TEXT nunca se envía a Telegram

Evidencia diagnóstica

La respuesta visible para el usuario existe en la transcripción de la sesión con marcas de tiempo de generación correctas pero nunca llega a la capa del canal:

$ openclaw session show --id session-abc123
Session Transcript:
  [10:30:01.234] ← HEARTBEAT (scheduled)
  [10:30:02.456] ← USER: "What's the weather?" (steered into heartbeat run)
  [10:30:02.789] → HEARTBEAT_OK
  [10:30:03.012] → TEXT: "The weather is sunny, 72°F"  ← EXISTS IN TRANSCRIPT
  [10:30:03.100] ← Delivered: HEARTBEAT_OK only         ← MISSING TEXT

Verificación de la solución alternativa

Cambiar al modo collect evita el problema:

# config.yaml
messages:
  queue:
    mode: "collect"  # ← Solución alternativa: coloca en cola como turno separado

En modo collect, el mensaje del usuario se coloca en cola de forma independiente y recibe su propia ejecución con entrega completa.

🧠 Causa raíz

Análisis arquitectónico: Supresión de respuestas a nivel de ejecución

El problema proviene de una suposición de diseño fundamental en la lógica de manejo de heartbeat: una ejecución que contiene HEARTBEAT_OK se clasifica como un "no-op de heartbeat" y se suprime la entrega completa de respuestas de dicha ejecución.

Secuencia de fallo

  1. Inyección en modo Steer: En modo steer, los mensajes de usuario entrantes que llegan durante una ejecución activa de heartbeat se inyectan en esa ejecución en lugar de crear una nueva. Esto es intencional—steer prioriza la latencia sobre el aislamiento.
  2. Generación de ejecución con múltiples respuestas: El agente procesa el contexto combinado de heartbeat + mensaje de usuario y produce múltiples respuestas en secuencia:
    Response 1: HEARTBEAT_OK    // El agente reconoce el heartbeat sin acción
    Response 2: TEXT           // El agente responde a la pregunta real del usuario
    
  3. Lógica de clasificación de ejecución: La lógica de clasificación de ejecución escanea las respuestas en busca de cualquier marcador HEARTBEAT_*:
    // Pseudocódigo de clasificación simplificado
    function classifyRun(responses):
      for response in responses:
        if response.type.startsWith("HEARTBEAT_"):
          return RUN_TYPE.HEARTBEAT_NOOP  // ← Activa la supresión
      return RUN_TYPE.NORMAL
    
  4. Supresión prematura: Porque HEARTBEAT_OK está presente, toda la ejecución se marca como HEARTBEAT_NOOP, activando la lógica de descarte en la capa de entrega saliente:
    // Pseudocódigo de entrega saliente
    function deliverResponses(run):
      if run.classification === RUN_TYPE.HEARTBEAT_NOOP:
        return  // ← Se omite toda la entrega, incluyendo la respuesta del usuario
      deliverToChannel(run.responses)
    
  5. Respuesta del usuario perdida: La respuesta TEXT (que es contenido válido orientado al usuario) se descarta porque comparte ejecución con HEARTBEAT_OK.

Referencia de ubicación del código

ComponenteArchivoProblema
Inyección Steersrc/queue/steer.tsInyecta mensajes de usuario en ejecuciones activas de heartbeat
Clasificación de ejecuciónsrc/runs/classifier.tsClasifica toda la ejecución basándose en la presencia de HEARTBEAT_*
Entrega salientesrc/outbound/delivery.tsOmite la entrega para ejecuciones HEARTBEAT_NOOP

Por qué el modo Collect funciona

En modo collect, el mensaje del usuario se coloca en cola como un turno de seguimiento separado. Recibe su propia ejecución independiente que contiene solo respuestas orientadas al usuario—sin marcadores HEARTBEAT_*—por lo que la lógica de clasificación lo identifica correctamente como NORMAL y lo entrega.

🛠️ Solución paso a paso

Opción 1: Filtrar respuestas antes de la clasificación (Recomendado)

Modificar la lógica de clasificación de ejecución para ignorar las respuestas HEARTBEAT_OK al determinar el tipo de ejecución, permitiendo que las respuestas orientadas al usuario en la misma ejecución se entreguen.

Antes

// src/runs/classifier.ts
function classifyRun(responses: Response[]): RunType {
  for (const response of responses) {
    if (response.type.startsWith("HEARTBEAT_")) {
      return RUN_TYPE.HEARTBEAT_NOOP;
    }
  }
  return RUN_TYPE.NORMAL;
}

Después

// src/runs/classifier.ts
function classifyRun(responses: Response[]): RunType {
  const hasHeartbeatAction = responses.some(
    r => r.type.startsWith("HEARTBEAT_") && r.type !== "HEARTBEAT_OK"
  );
  const hasUserFacingResponse = responses.some(
    r => !r.type.startsWith("HEARTBEAT_") && r.type !== "CONTROL"
  );

if (hasHeartbeatAction && !hasUserFacingResponse) { return RUN_TYPE.HEARTBEAT_NOOP; } return RUN_TYPE.NORMAL; }

Opción 2: Modificar la entrega saliente para extraer respuestas de usuario

Si la clasificación no puede cambiarse, modificar la capa de entrega saliente para extraer y entregar respuestas no relacionadas con heartbeat incluso de ejecuciones HEARTBEAT_NOOP.

// src/outbound/delivery.ts
function deliverResponses(run: Run): void {
  if (run.classification !== RUN_TYPE.HEARTBEAT_NOOP) {
    deliverToChannel(run.responses);
    return;
  }
  
  // Extraer respuestas orientadas al usuario de ejecuciones heartbeat no-op
  const userResponses = run.responses.filter(
    r => !r.type.startsWith("HEARTBEAT_") && r.type !== "CONTROL"
  );
  
  if (userResponses.length > 0) {
    deliverToChannel(userResponses);
  }
}

Opción 3: Solución basada en configuración

Si los cambios de código no son posibles inmediatamente, configurar el sistema para evitar la condición:

# config.yaml
messages:
  queue:
    mode: "collect"  # Evita la inyección guiada en ejecuciones de heartbeat
    
heartbeat:
  interval: 60s     # Reduce la probabilidad de que el mensaje de usuario llegue durante el heartbeat
  # O deshabilitar heartbeat durante conversaciones activas:
  pause_on_active: true

Pasos de despliegue

  1. Respaldar configuración
    cp config.yaml config.yaml.backup
  2. Aplicar la solución
    # Si usa Opción 1 o 2:
    vim src/runs/classifier.ts   # o src/outbound/delivery.ts
    npm run build
  3. Reiniciar el servicio
    docker-compose down && docker-compose up -d
    # O para systemd:
    sudo systemctl restart openclaw
  4. Verificar configuración
    openclaw config show | grep -A5 "queue:"
    openclaw status

🧪 Verificación

Caso de prueba 1: Entrega de mensaje guiado

Propósito: Verificar que los mensajes de usuario inyectados en ejecuciones de heartbeat se entreguen.

# 1. Configurar modo steer con heartbeat corto
openclaw config set messages.queue.mode steer
openclaw config set heartbeat.interval 3s
openclaw restart

# 2. Monitorear logs en una terminal
openclaw logs --follow | grep -E "(HEARTBEAT|steer|deliver)"

# 3. Enviar mensaje de usuario durante la ventana de heartbeat
# Esperar la línea de log: [heartbeat] HEARTBEAT_RUN triggered
# Luego enviar inmediatamente: "Testing steer delivery"

# 4. Verificar entrega
# Esperado: El usuario recibe respuesta en Telegram
# Log esperado: [outbound] Delivered: TEXT response to user

Criterios de éxito:

  • El usuario recibe la respuesta en Telegram
  • El log muestra Delivered: TEXT (no Discarded)
  • La transcripción de la sesión muestra tanto HEARTBEAT_OK como respuestas TEXT

Caso de prueba 2: Heartbeat puro aún suprimido

Propósito: Asegurar que las ejecuciones genuinas de HEARTBEAT_NOOP (sin mensajes de usuario) sigan suprimidas.

# 1. Esperar a que el heartbeat se active SIN interacción del usuario
# Monitorear logs para una ejecución limpia de heartbeat

# 2. Comportamiento esperado del log
[heartbeat] HEARTBEAT_RUN triggered
[heartbeat] HEARTBEAT_OK generated (no user message)
[outbound] Discarding run (HEARTBEAT_NOOP)

# 3. Verificar: El usuario NO debe recibir ninguna notificación de esta ejecución

Criterios de éxito:

  • Las ejecuciones de solo heartbeat no producen notificación al usuario
  • El log muestra Discarding para ejecuciones puras de heartbeat

Caso de prueba 3: Validación de transcripción de sesión

# Obtener ID de sesión de una conversación reciente
openclaw session list --limit 5

Mostrar transcripción detallada

openclaw session show –id <SESSION_ID> –verbose

Verificar estructura contiene ambos tipos de respuesta

La salida esperada debe mostrar: [timestamp] → HEARTBEAT_OK [timestamp] → TEXT: “response content” [timestamp] ← Delivered: HEARTBEAT_OK, TEXT ← Ambos entregados

Pruebas de regresión

# Ejecutar suite de pruebas existente
npm test -- --grep "heartbeat"

Ejecutar pruebas específicas del modo steer

npm test – –grep “steer”

Esperado: Todas las pruebas pasan incluyendo:

- Inyección de mensajes en modo steer

- Clasificación de respuestas de heartbeat

- Filtrado de entrega saliente

Verificación de código de salida

# Después del despliegue de la solución
openclaw health check; echo "Exit code: $?"
# Esperado: 0 (saludable)

Verificar logs del servicio

docker-compose logs openclaw 2>&1 | tail -20

Esperado: Sin entradas de nivel ERROR relacionadas con la entrega

⚠️ Errores comunes

Trampas específicas del entorno

EntornoTrampaMitigación
DockerLa desviación del reloj del contenedor puede hacer que el tiempo del heartbeat sea poco confiable, exacerbando la condición de carrera entre las ejecuciones de heartbeat y la inyección de mensajes de usuarioAsegurar sincronización NTP: docker run --cap-add=SYS_TIME openclaw:latest
VPS/CloudLa latencia de red entre la API de Telegram y el servidor puede enmascarar la sensibilidad de tiempo del problemaProbar con webhook local del bot en lugar de long-polling para reducir variables
macOS (dev)Los temporizadores de heartbeat pueden activarse de manera menos confiable debido a estados de suspensión/hibernación del sistemaDeshabilitar suspensión del sistema durante pruebas: caffeinate -s
Windows (dev)Las diferencias de fin de línea (\r\n vs \n) en archivos de configuración pueden causar problemas de análisisUsar finales de línea Unix: set FILE_OPTS=-o nowrap en el editor

Sensibilidad al tiempo

El error depende altamente del tiempo. La ventana de carrera existe entre:

  1. La ejecución de heartbeat inicia (HEARTBEAT_RUN registrado)
  2. El ack de heartbeat se genera (HEARTBEAT_OK registrado)
  3. El mensaje de usuario llega y se inyecta
  4. La respuesta del usuario se genera
  5. La clasificación de ejecución ocurre

Recomendación: Usar un intervalo de heartbeat de 3s o 5s para reproducción confiable en pruebas. Intervalos menores a 1s pueden hacer que la condición de carrera sea demasiado ajustada para activarse de manera confiable.

Errores de configuración

  • Confundir steer con force:
    # Incorrecto - el modo force ignora la cola completamente
    messages.queue.mode: "force"
    

    Correcto - el modo steer usa inyección inteligente

    messages.queue.mode: “steer”

  • Intervalo de heartbeat demasiado largo que enmascara el problema:
    # Este intervalo puede nunca superponerse con mensajes de usuario
    heartbeat.interval: 300s  # 5 minutos - improbable atrapar mensajes de usuario
    

    Mejor para pruebas

    heartbeat.interval: 3s

  • Configuración de cola específica de canal faltante:
    # Algunos canales pueden sobrescribir el modo de cola global
    telegram:
      queue_mode: "collect"  # ← Puede sobrescribir la configuración de steer
    

    Usar configuración agnóstica del canal

    messages.queue.mode: “steer”

Diagnóstico incorrecto: Confundir síntomas

Estos problemas pueden parecer similares pero tienen diferentes causas raíz:

  • Respuesta faltante debido a limitación de tasa: El usuario no recibe nada, pero el log muestra Rate limited en lugar de Discarded
  • Respuesta faltante debido a silencio del agente: El agente nunca genera una respuesta, el log no muestra TEXT en el array de respuestas
  • Respuesta faltante debido a fallo de entrega de Telegram: El log muestra Delivered pero el usuario no recibe; esto es un problema de Telegram/API

Diferenciador clave: Este error muestra Discarding run en los logs con ambos HEARTBEAT_OK y TEXT presentes en la transcripción.

Trampas de solución parcial

Si se aplica la Opción 2 (extracción saliente), asegurar que:

  • Las respuestas de control (ej., HANDOVER, TRANSFER) también se filtren apropiadamente
  • Las llamadas de análisis/seguimiento aún reciban el array completo de respuestas
  • Los payloads de webhook reflejen el contenido realmente entregado, no la ejecución original

🔗 Errores relacionados

  • HEARTBEAT_TIMEOUT — La ejecución de heartbeat excedió la duración máxima; puede activar limpieza de sesión si los timeouts consecutivos exceden el umbral
    [heartbeat] Run exceeded 30s timeout, forcing HEARTBEAT_TIMEOUT
    
  • HEARTBEAT_SKIP — La ejecución de heartbeat se omitió debido a conversación activa; configuración heartbeat.pause_on_active: true
    [heartbeat] Skipping HEARTBEAT_RUN - active conversation detected
    
  • STEER_INJECT_FAILED — El modo steer falló al inyectar mensaje en ejecución activa; vuelve a colocar en cola
    [steer] Failed to inject message: no active heartbeat run in progress
    [steer] Falling back to queue for message "..."
    
  • DELIVERY_FILTERED — La respuesta fue filtrada intencionalmente por política del canal (detección de spam, filtrado de contenido)
    [outbound] DELIVERY_FILTERED: response contains blocked keyword
    
  • RATE_LIMIT_EXCEEDED — Límite de tasa de API de Telegram alcanzado; respuestas en cola para reintento
    [telegram] Rate limit exceeded (30/30), queuing response for retry in 60s
    
  • QUEUE_MODE_CONFLICT — Configuración de modo de cola conflictiva entre configuración global y específica del canal
    [config] Warning: telegram.queue_mode conflicts with messages.queue.mode
    

Contexto histórico

Este problema representa una regresión introducida cuando el sistema de clasificación de ejecución se unificó para eficiencia. Anteriormente, cada tipo de respuesta tenía lógica de entrega independiente, pero la consolidación en clasificación a nivel de ejecución introdujo el comportamiento de supresión para ejecuciones con múltiples respuestas que contenían HEARTBEAT_OK.

Ver también

Evidencia y fuentes

Esta guía de solución de problemas fue sintetizada automáticamente por la tubería de inteligencia de FixClaw a partir de las discusiones de la comunidad.