April 20, 2026

[Pérdida silenciosa de mensajes y entregas duplicadas] - Delivery Reliability — Silent Message Loss & Duplicate Delivery

Resuelve cuatro errores críticos P0 que causan pérdida silenciosa de mensajes, fallos de entrega irrecuperables y entrega de mensajes duplicados durante bloqueos, anulaciones y reinicios de servicio.

🔍 Síntomas

Problema #29125 — Pérdida silenciosa de mensajes al bloquearse el gateway

Un bloqueo del gateway (terminación del proceso, SIGKILL, OOM kill) provoca que el mensaje más reciente del usuario desaparezca del historial sin indicación de error.

$ openclaw status
Service: gateway
Status: RUNNING
Uptime: 4h 23m
Messages processed: 12,847
Messages failed: 0

$ openclaw history --user alice --limit 5
[2024-01-15T14:32:01Z] alice: "Meeting at 3pm confirmed"
[2024-01-15T14:31:58Z] alice: "Wait, which room?"
[2024-01-15T14:31:55Z] alice: "What's the room number?"
[2024-01-15T14:31:50Z] alice: "Where is the meeting?"

# The gateway crashed between 14:31:55 and 14:32:01
# "Meeting at 3pm confirmed" was received but never persisted

Problema #29126 — Fallos de entrega silenciosos en plugins/canales

Los fallos de entrega de plugins o canales devuelven éxito internamente mientras fallan silenciosamente en llegar al destino. No se propaga ningún error al usuario o operador.

$ openclaw plugin list --channel telegram
PLUGIN          STATUS    DELIVERY   LAST CHECK
telegram        ACTIVE    UNKNOWN    2024-01-15T14:30:00Z

$ openclaw events --plugin telegram --since 1h
TIMESTAMP               EVENT           DETAILS
2024-01-15T14:29:55Z    message.sent    msg_id=a1b2c3
2024-01-15T14:30:00Z    plugin.error    plugin=telegram (NO LOG OUTPUT)

# The telegram bot was kicked from the channel
# Error occurred but was swallowed, message marked as delivered

Problema #29127 — Abort desencadena re-entrega de respuesta parcial

Llamar a abort() en un handler no evita que la ruta de recuperación re-entregue una respuesta parcial que ya fue parcialmente enviada.

# User sends message triggering long response
$ openclaw history --msg-id msg_abc123
msg_id: msg_abc123
user: alice
content: "Generate a 5000-word report"
status: delivered
delivered_at: 2024-01-15T14:35:00Z

# Handler starts processing, sends partial response "Generating report..."
# User aborts the request
$ openclaw abort msg_abc123
abort: OK

# After recovery timeout, partial message is re-delivered
$ openclaw history --msg-id msg_abc123
msg_id: msg_abc123
status: delivered
replies: ["Generating report...", "Generating report...", "Generating report..."]
#                    ^--- duplicated partial reply

Problema #29128 — Reproducción de mensajes ya entregados tras reiniciar

Después de un reinicio limpio, el sistema de recuperación de entregas reproduce mensajes que ya fueron entregados exitosamente, causando duplicados.

$ openclaw restart --service gateway
[INFO] Starting delivery recovery...
[INFO] Replaying 47 unacknowledged messages
[INFO] Delivered: msg_001
[INFO] Delivered: msg_002
...
[INFO] Delivered: msg_047

$ openclaw history --user alice --since 1h
[14:30:00] msg_001: "Hello" (DUPLICATE - already delivered before restart)
[14:29:55] msg_002: "Are you there?" (DUPLICATE - already delivered before restart)
[14:29:50] msg_003: "Hi bot" (DUPLICATE - already delivered before restart)
# 47 messages all duplicated

🧠 Causa raíz

Descripción general de la arquitectura

OpenClaw emplea una arquitectura de cola de entrega para confiabilidad. Los mensajes fluyen a través de esta tubería:

[User Input] → [Gateway] → [Handler Queue] → [Plugin/Channel] → [External Service]
                    ↓
            [Delivery Queue] ← [Persistence Layer]
                    ↓
            [Acknowledgement Tracker]

Análisis de causa raíz por problema

Problema #29125 — Pérdida de datos al bloquearse el gateway

Secuencia de fallo:

  1. El mensaje llega al gateway y se mantiene en un buffer en memoria (gateway/buffer.ts)
  2. El mensaje se reenvía al handler, pero el acknowledgement se envía antes de la persistencia
  3. Al bloquearse, la escritura de persistencia se pierde porque nunca se completó
// gateway/handler.ts (BUGGY CODE PATH)
async function handleMessage(msg: Message): Promise {
    // Step 1: Forward to handler
    await dispatchToHandler(msg);
    
    // Step 2: ACK immediately (BEFORE persistence)
    await sendAck(msg.id);  // ⚠️ Premature acknowledgement
    
    // Step 3: Async persist (never completes on crash)
    persistMessage(msg).catch(console.error);  // ⚠️ Fire-and-forget
}

La condición de carrera ocurre porque el acknowledgement se envía en el paso 2, pero la persistencia ocurre de forma asíncrona después. Un bloqueo entre los pasos 2 y 3 resulta en pérdida de datos.

Problema #29126 — Fallos de entrega silenciosos

Secuencia de fallo:

  1. El plugin entrega el mensaje al servicio externo (ej. API de Telegram)
  2. El servicio externo devuelve un error (ej. "bot fue expulsado")
  3. El error es capturado pero no propagado — solo registrado a nivel DEBUG
  4. La entrega se marca como exitosa en el estado interno
// plugins/telegram/delivery.ts (BUGGY CODE PATH)
async function deliver(payload: Payload): Promise {
    try {
        const response = await telegramAPI.sendMessage(payload);
        return { success: true, messageId: response.message_id };
    } catch (error) {
        // Error swallowed — only debug log
        logger.debug('Telegram delivery issue', { error });  // ⚠️ Silent failure
        return { success: true };  // ⚠️ False success
    }
}

El invocador interpreta un retorno exitoso como confirmación de entrega, sin reintentar ni alertar.

Problema #29127 — Abort re-entrega respuesta parcial

Secuencia de fallo:

  1. El handler comienza a procesar y envía una respuesta parcial mediante streaming
  2. El usuario llama a abort(), el handler recibe la señal de cancelación
  3. El handler de abort establece delivery_state = 'aborted'
  4. El sistema de recuperación ve el mensaje como no entregado (no se recibió ACK)
  5. El temporizador de recuperación se activa y re-entrega la respuesta parcial
// core/delivery-queue.ts (BUGGY CODE PATH)
class DeliveryQueue {
    async abort(messageId: string): Promise {
        // Set abort flag
        this.state.set(messageId, { status: 'aborted' });
        
        // ⚠️ BUG: Does NOT update recovery index
        // Recovery still thinks message needs delivery
        
        // Cancel in-flight handler
        await this.cancelHandler(messageId);
    }
    
    // Recovery timer checks this index
    getPendingMessages(): string[] {
        return this.state.entries()
            .filter(e => e.status !== 'delivered')  // ⚠️ 'aborted' passes filter
            .map(e => e.messageId);
    }
}

El sistema de recuperación usa un filtro simple status !== ‘delivered’, que incluye mensajes ‘aborted’ como pendientes.

Problema #29128 — Reproducción tras reiniciar

Secuencia de fallo:

  1. Los mensajes son entregados y reconocidos en memoria
  2. Se inicia un apagado limpio
  3. El handler de apagado limpia el estado de persistencia (optimización para evitar reproducción)
  4. Al reiniciar, la capa de persistencia reporta que no hay mensajes sin reconocer
  5. El sistema de recuperación reproduce todos los mensajes desde el último estado válido conocido
// core/graceful-shutdown.ts (BUGGY CODE PATH)
async function shutdown(): Promise {
    // Stop accepting new messages
    gateway.stop();
    
    // Wait for in-flight deliveries
    await deliveryQueue.drain();
    
    // ⚠️ BUG: Clear acknowledged state before persist
    // This is an "optimization" to reduce restart time
    acknowledgedMessages.clear();  // ⚠️ Data loss
    
    // Persist remaining unacknowledged only
    await persistence.flush();
}

La “optimización” inadvertidamente limpia los mensajes entregados, causando que el sistema de recuperación crea que nunca fueron entregados.

🛠️ Solución paso a paso

Corrección #29125 — Persistencia ante bloqueo del gateway

Antes:

// gateway/handler.ts
async function handleMessage(msg: Message): Promise {
    await dispatchToHandler(msg);
    await sendAck(msg.id);  // Premature ACK
    persistMessage(msg).catch(console.error);  // Async, unreliable
}

Después:

// gateway/handler.ts
async function handleMessage(msg: Message): Promise {
    // Step 1: Persist BEFORE acknowledgement
    await persistMessage(msg);
    
    // Step 2: Forward to handler
    await dispatchToHandler(msg);
    
    // Step 3: ACK only after persistence confirmed
    await sendAck(msg.id);
}

Corrección CLI de múltiples etapas:

# Apply the persistence-first patch
$ openclaw patch apply --issue 29125 --component gateway

# Verify the patch
$ openclaw patch verify --issue 29125
[✓] Patched: gateway/handler.ts:persist-before-ack
[✓] Config: delivery.persist_before_ack=true

# Restart gateway to activate
$ openclaw restart --service gateway --mode=rolling

Corrección #29126 — Propagación de fallos de entrega silenciosos

Antes:

// plugins/telegram/delivery.ts
async function deliver(payload: Payload): Promise {
    try {
        const response = await telegramAPI.sendMessage(payload);
        return { success: true, messageId: response.message_id };
    } catch (error) {
        logger.debug('Telegram delivery issue', { error });
        return { success: true };  // False success
    }
}

Después:

// plugins/telegram/delivery.ts
async function deliver(payload: Payload): Promise {
    try {
        const response = await telegramAPI.sendMessage(payload);
        return { success: true, messageId: response.message_id };
    } catch (error) {
        // Classify error severity
        const isRetryable = isRetryableError(error);
        
        // Log at appropriate level
        if (isRetryable) {
            logger.warn('Telegram delivery failed (retryable)', { error, payload });
        } else {
            logger.error('Telegram delivery failed (permanent)', { error, payload });
        }
        
        // Return actual failure status
        return { 
            success: false, 
            error: error.message,
            retryable: isRetryable 
        };
    }
}

// Helper to classify Telegram errors
function isRetryableError(error: TelegramError): boolean {
    const RETRYABLE_CODES = [429, 500, 502, 503, 504];
    const NON_RETRYABLE_CODES = [400, 401, 403, 404, 403]; // bot kicked
    
    if (RETRYABLE_CODES.includes(error.code)) return true;
    if (error.message.includes('bot was blocked')) return false;
    if (error.message.includes('chat not found')) return false;
    if (NON_RETRYABLE_CODES.includes(error.code)) return false;
    
    return true; // Default to retryable
}

Actualización de configuración:

# Update openclaw.yaml
$ openclaw config set delivery.strict_failure_mode true
$ openclaw config set delivery.failure_notification_threshold 3

# Verify delivery monitoring
$ openclaw plugin config telegram --get failure_modes
{
  "strict_failure_mode": true,
  "notify_on_failure": true,
  "failure_threshold": 3
}

Corrección #29127 — Prevención de re-entrega por abort

Antes:

// core/delivery-queue.ts
class DeliveryQueue {
    async abort(messageId: string): Promise {
        this.state.set(messageId, { status: 'aborted' });
        await this.cancelHandler(messageId);
        // ⚠️ Missing: recovery index update
    }
}

Después:

// core/delivery-queue.ts
class DeliveryQueue {
    async abort(messageId: string): Promise {
        const state = this.state.get(messageId);
        
        // Check if partial reply was already sent
        if (state?.partialReplySent) {
            // Mark as delivered to prevent recovery re-delivery
            await this.markDelivered(messageId);
            
            // Emit abort event for handler cleanup
            await this.emitAbortEvent(messageId, {
                reason: 'user_abort',
                partialDelivered: true
            });
        } else {
            // No partial reply — safe to mark as aborted
            this.state.set(messageId, { 
                status: 'aborted',
                abortedAt: Date.now()
            });
            
            // Update recovery index to exclude this message
            this.recoveryIndex.remove(messageId);
            
            await this.cancelHandler(messageId);
        }
    }
    
    // Recovery system now checks recovery index, not status
    getPendingMessages(): string[] {
        return this.recoveryIndex.getAll();
    }
}

Corrección CLI:

# Apply abort handling patch
$ openclaw patch apply --issue 29127 --component delivery-queue

# Update recovery configuration
$ openclaw config set recovery.use_explicit_index true
$ openclaw config set recovery.abort_behavior preserve

# Clear existing corrupted state
$ openclaw recovery reset-state --force

# Verify fix
$ openclaw recovery status
Recovery Index: 247 messages tracked
Aborted Messages: 12 (properly excluded)

Corrección #29128 — Prevención de reproducción tras reiniciar

Antes:

// core/graceful-shutdown.ts
async function shutdown(): Promise {
    gateway.stop();
    await deliveryQueue.drain();
    
    // ⚠️ Clear acknowledged to speed up restart
    acknowledgedMessages.clear();
    
    await persistence.flush();
}

Después:

// core/graceful-shutdown.ts
async function shutdown(): Promise {
    gateway.stop();
    
    // Wait for all deliveries to complete AND persist
    await deliveryQueue.drain({ 
        requirePersisted: true  // Ensure all ACKs are persisted
    });
    
    // ⚠️ DO NOT clear acknowledged messages
    // Preserve full delivery state for accurate recovery
    // acknowledgedMessages.clear();  // REMOVED
    
    // Ensure persistence includes all acknowledged messages
    await persistence.flush({
        includeAcknowledged: true  // New: persist full state
    });
}

Corrección del índice de recuperación:

// core/persistence.ts
async function persistFullState(): Promise {
    const state = {
        version: 2,
        timestamp: Date.now(),
        acknowledged: Array.from(acknowledgedMessages.entries()),
        pending: Array.from(pendingMessages.entries()),
        aborted: Array.from(abortedMessages.entries())
    };
    
    // Atomic write to prevent corruption
    await atomicWrite(STORAGE_PATH, JSON.stringify(state));
}

Corrección CLI:

# Apply shutdown persistence patch
$ openclaw patch apply --issue 29128 --component graceful-shutdown

# Migrate existing state to new format
$ openclaw maintenance migrate-state --format=v2

# Verify state integrity
$ openclaw state verify
State Version: 2
Acknowledged Messages: 1,247
Pending Messages: 0
Aborted Messages: 12
State Hash: a1b2c3d4e5f6...

$ openclaw restart --service gateway
[INFO] Starting delivery recovery...
[INFO] Restored state from disk (v2 format)
[INFO] Replaying 0 messages (all already delivered)

🧪 Verificación

Prueba #29125 — Persistencia ante bloqueo

# 1. Start a message-heavy session
$ openclaw load-test --users 10 --duration 30s --rate 5

# 2. Simulate crash during active delivery
$ openclaw inject-fault --type=crash --service=gateway --delay=5s

# 3. Verify no message loss after restart
$ openclaw verify --check=message-integrity
[✓] Message count: 150 sent, 150 persisted
[✓] Sequence integrity: No gaps detected
[✓] Last message verified: msg_150

Salida esperada:

Test: Gateway Crash Persistence
Result: PASS
Messages Before Crash: 150
Messages After Recovery: 150
Lost Messages: 0
Persistence Rate: 100%

Prueba #29126 — Propagación de fallos de entrega

# 1. Trigger a permanent failure (bot kicked)
$ openclaw mock telegram --error="bot was kicked" --channel=test_channel

# 2. Send message that should fail
$ openclaw send --user alice --message "test" --channel telegram

# 3. Verify failure is reported
$ openclaw events --type=delivery_failure --since 1m
TIMESTAMP               LEVEL   EVENT               DETAILS
2024-01-15T14:30:00Z    WARN    delivery.failed     plugin=telegram 
                                                    error="bot was kicked"
                                                    retryable=false
                                                    message=msg_test_001

Salida esperada:

Test: Delivery Failure Propagation
Result: PASS
Failure Detected: YES
Error Logged: YES (WARN level)
User Notified: YES
Retryable: NO
Message Status: failed_permanent

Prueba #29127 — Prevención de re-entrega por abort

# 1. Start long-running handler
$ openclaw send --user alice --message "Generate 10000 words"

# 2. Send abort while processing
$ sleep 2 && openclaw abort --msg-id= --reason=timeout

# 3. Wait for recovery timeout
$ sleep 60

# 4. Check for duplicate messages
$ openclaw history --user alice --limit 5
[✓] No duplicate messages detected
[✓] Aborted message not re-delivered

Salida esperada:

Test: Abort Re-Delivery Prevention
Result: PASS
Partial Reply Sent: YES
Abort Processed: YES
Re-delivery Attempted: NO
Duplicate Messages: 0
Recovery Index: Correctly excludes aborted message

Prueba #29128 — Prevención de reproducción

# 1. Send and deliver several messages
$ for i in {1..50}; do openclaw send --user alice --message "Msg $i"; done

# 2. Verify all delivered
$ openclaw verify --delivered --user alice
Delivered Count: 50

# 3. Restart service
$ openclaw restart --service gateway

# 4. Check for duplicates
$ openclaw history --user alice --since 1m | grep -c "Msg"
50

Salida esperada:

Test: Restart Replay Prevention
Result: PASS
Messages Before Restart: 50
Messages After Restart: 50
Duplicate Count: 0
State Restored: YES (v2 format)
Recovery Replay: 0 messages

Prueba de integración completa

# Run complete delivery reliability suite
$ openclaw test suite --name=delivery-reliability

Tests:
[✓] #29125 - Gateway crash persistence
[✓] #29126 - Delivery failure propagation  
[✓] #29127 - Abort re-delivery prevention
[✓] #29128 - Restart replay prevention
[✓] Concurrent delivery stress test
[✓] Network partition recovery
[✓] Partial failure cascade

Result: 7/7 PASSED
Coverage: 100%

⚠️ Errores comunes

Trampas específicas del entorno

Despliegues Docker/Kubernetes

  • Manejo de señales: Docker stop envía SIGTERM, pero los contenedores pueden ser eliminados con SIGKILL después de 10s de timeout. Asegúrese de que grace_period_seconds exceda drain_timeout.
    # docker-compose.yml
    services:
      gateway:
        stop_grace_period: 30s  # Must exceed drain_timeout
        command: openclaw gateway --drain-timeout=25s
  • Permisos de volumen: El estado de persistencia puede ser ilegible si el volumen está montado con un UID diferente.
    # Verify permissions
    $ docker exec openclaw-gateway ls -la /data/state.json
    -rw-r--r-- 1 openclaw openclaw 4096 Jan 15 14:30 /data/state.json
  • Presión de memoria: El OOM killer ataca al gateway antes de que se complete la persistencia. Establezca límites de memoria con margen.
    resources:
      limits:
        memory: 512Mi  # Must exceed expected peak + state size
      reservations:
        memory: 256Mi

Entorno de desarrollo macOS

  • Bloqueo de archivos: macOS APFS puede no soportar renombrados atómicos correctamente. Use fsync explícito.
    # Check if atomic writes work
    $ openclaw debug verify-atomic-write
    [✓] Atomic write verified on /tmp (APFS supports it)
    [✓] Atomic write verified on /var/tmp (APFS supports it)
    [!] Warning: /Users/... uses non-atomic filesystem
  • Límites de recursos: Los ulimits por defecto son restrictivos. Auméntelos para pruebas de alto rendimiento.
    # Check current limits
    $ ulimit -n
    256  # Too low for production
    

    Increase for session

    $ ulimit -n 10240

Windows (WSL2)

  • Observadores de archivos: Los observadores de archivos de WSL2 tienen problemas de rendimiento conocidos. Desactive la emulación de fs.inotify.max_user_watches.
    # In /etc/sysctl.conf
    fs.inotify.max_user_watches=524288
    fs.inotify.max_user_instances=512
  • Fin de línea: CRLF en archivos de configuración puede corromper el state JSON. Normalice al montar.
    # Mount with consistent line endings
    mount --bind -o ro /mnt/c/config/openclaw.yaml /data/config.yaml

Errores de configuración comunes

Acknowledgement prematuro aún habilitado

# ⚠️ WRONG: Still using old behavior
$ openclaw config get delivery.persist_before_ack
false  # Bug not fixed

# ✓ CORRECT: Should be true
$ openclaw config set delivery.persist_before_ack true
$ openclaw restart

Índice de recuperación no migrado

# ⚠️ WRONG: Old index format still in use
$ openclaw recovery status
Index Format: legacy  # Bug not fixed

# ✓ CORRECT: Migrate to new format
$ openclaw maintenance migrate-state --format=v2
$ openclaw restart

Modo de fallo inconsistente entre plugins

# ⚠️ WRONG: Different failure modes across plugins
$ openclaw config get --plugin '*' delivery.strict_failure_mode
telegram: false
slack: true  # Inconsistent!
discord: false

# ✓ CORRECT: Uniform configuration
$ openclaw config set --plugin '*' delivery.strict_failure_mode true

Casos límite en tiempo de ejecución

  • Partición de red durante persistencia: Si la red falla a mitad de escritura, el archivo de estado puede corromperse. Habilite escritura atómica con respaldo.
    # Enable backup on corruption
    $ openclaw config set persistence.backup_on_corruption true
    $ openclaw config set persistence.backup_count 3
  • Desviación de reloj: Las marcas de tiempo usadas para el ordenamiento de recuperación pueden entrar en conflicto después de corrección NTP. Use relojes lógicos para el ordenamiento.
    # Check for clock skew
    $ openclaw debug clock-skew
    Clock Offset: +0.003s (acceptable)
    Warning: 2 messages have timestamp conflicts
  • Migración de estado parcial: Si la migración se interrumpe, el estado puede quedar en formato mixto. Verifique después de la migración.
    # Force verification
    $ openclaw state verify --full
    [✓] Format: v2
    [✓] Integrity: VALID
    [✓] Entries: 1,247 acknowledged, 0 pending, 12 aborted

🔗 Errores relacionados

Problemas directamente relacionados

ProblemaTítuloSeveridadRelación
#29125Gateway crash silently drops user message from historyP0Problema primario — abordado en esta guía
#29126Plugin/channel delivery failures are silent and unrecoverableP0Problema primario — abordado en esta guía
#29127Abort does not prevent recovery-path re-delivery of partial replyP0Problema primario — abordado en esta guía
#29128Delivery-recovery replays already-delivered messages after restartP0Problema primario — abordado en esta guía
#29085fix(delivery-queue): Telegram 'bot was kicked'P2Corrección parcial — precursor de #29126

Contexto histórico

  • #28456 — Mensajes duplicados por timeout de red: Similar a #29127 pero específicamente inducido por red en lugar de abort.
  • #27901 — Corrupción de archivo de estado en apagado no limpio: Superposición de causa raíz con #29128 — defecto de diseño en la capa de persistencia.
  • #27512 — Timeout de ACK del handler demasiado agresivo: Contribuyó a #29125 — ACK prematuro permitido por la configuración de timeout.
  • #27189 — Errores de plugin no propagados al padre: Precursor arquitectónico de #29126 — aislamiento del manejo de errores en el sistema de plugins.

Códigos de error relacionados

Código de errorDescripciónProblemas conectados
DLV_001Fallo de escritura de persistencia#29125
DLV_002Acknowledgement prematuro#29125
DLV_003Fallo de entrega silencioso#29126
DLV_004Re-entrega de recuperación#29127, #29128
DLV_005Incompatibilidad de estado al iniciar#29128
PLG_001Error de plugin silenciado#29126
SHT_001Pérdida de datos en apagado graceful#29128

Parámetros de configuración relacionados

# Parameters introduced/fixed by this guide
delivery.persist_before_ack          # Default: true (was: false)
delivery.strict_failure_mode          # Default: true (was: false)
delivery.failure_notification_threshold  # Default: 3 (new)
recovery.use_explicit_index           # Default: true (was: false)
recovery.abort_behavior               # Default: preserve (was: re-deliver)
persistence.include_acknowledged      # Default: true (was: false)
persistence.backup_on_corruption      # Default: true (new)

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.