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
- Configurar el modo cola como
steer:# config.yaml messages: queue: mode: "steer" heartbeat: interval: 3s - Esperar a que se active una ejecución de heartbeat (visible en los logs como
HEARTBEAT_RUN) - Enviar un mensaje de usuario dentro de la ventana de heartbeat
- 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) - 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
- 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—steerprioriza la latencia sobre el aislamiento. - 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 - 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 - Supresión prematura: Porque
HEARTBEAT_OKestá presente, toda la ejecución se marca comoHEARTBEAT_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) - Respuesta del usuario perdida: La respuesta
TEXT(que es contenido válido orientado al usuario) se descarta porque comparte ejecución conHEARTBEAT_OK.
Referencia de ubicación del código
| Componente | Archivo | Problema |
|---|---|---|
| Inyección Steer | src/queue/steer.ts | Inyecta mensajes de usuario en ejecuciones activas de heartbeat |
| Clasificación de ejecución | src/runs/classifier.ts | Clasifica toda la ejecución basándose en la presencia de HEARTBEAT_* |
| Entrega saliente | src/outbound/delivery.ts | Omite 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
- Respaldar configuración
cp config.yaml config.yaml.backup - Aplicar la solución
# Si usa Opción 1 o 2: vim src/runs/classifier.ts # o src/outbound/delivery.ts npm run build - Reiniciar el servicio
docker-compose down && docker-compose up -d # O para systemd: sudo systemctl restart openclaw - 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(noDiscarded) - La transcripción de la sesión muestra tanto
HEARTBEAT_OKcomo respuestasTEXT
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
Discardingpara 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
| Entorno | Trampa | Mitigación |
|---|---|---|
| Docker | La 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 usuario | Asegurar sincronización NTP: docker run --cap-add=SYS_TIME openclaw:latest |
| VPS/Cloud | La latencia de red entre la API de Telegram y el servidor puede enmascarar la sensibilidad de tiempo del problema | Probar 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 sistema | Deshabilitar 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álisis | Usar 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:
- La ejecución de heartbeat inicia (
HEARTBEAT_RUNregistrado) - El ack de heartbeat se genera (
HEARTBEAT_OKregistrado) - El mensaje de usuario llega y se inyecta
- La respuesta del usuario se genera
- 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
steerconforce:# 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 usuarioMejor 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 steerUsar 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 limiteden lugar deDiscarded - Respuesta faltante debido a silencio del agente: El agente nunca genera una respuesta, el log no muestra
TEXTen el array de respuestas - Respuesta faltante debido a fallo de entrega de Telegram: El log muestra
Deliveredpero 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_TIMEOUTHEARTBEAT_SKIP— La ejecución de heartbeat se omitió debido a conversación activa; configuraciónheartbeat.pause_on_active: true[heartbeat] Skipping HEARTBEAT_RUN - active conversation detectedSTEER_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 keywordRATE_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 60sQUEUE_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
- Documentación de modos de cola de OpenClaw — Explicación detallada de
steervscollectvsforce - Arquitectura del sistema de Heartbeat — Inmersión técnica en programación de heartbeat y manejo de respuestas
- Adaptador de canal de Telegram — Consideraciones de entrega específicas del canal