[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 persistedProblema #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 deliveredProblema #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 replyProblema #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:
- El mensaje llega al gateway y se mantiene en un buffer en memoria (
gateway/buffer.ts) - El mensaje se reenvía al handler, pero el acknowledgement se envía antes de la persistencia
- 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:
- El plugin entrega el mensaje al servicio externo (ej. API de Telegram)
- El servicio externo devuelve un error (ej. "bot fue expulsado")
- El error es capturado pero no propagado — solo registrado a nivel DEBUG
- 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:
- El handler comienza a procesar y envía una respuesta parcial mediante streaming
- El usuario llama a
abort(), el handler recibe la señal de cancelación - El handler de abort establece
delivery_state = 'aborted' - El sistema de recuperación ve el mensaje como no entregado (no se recibió ACK)
- 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:
- Los mensajes son entregados y reconocidos en memoria
- Se inicia un apagado limpio
- El handler de apagado limpia el estado de persistencia (optimización para evitar reproducción)
- Al reiniciar, la capa de persistencia reporta que no hay mensajes sin reconocer
- 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=rollingCorrecció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_150Salida 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_001Salida esperada:
Test: Delivery Failure Propagation
Result: PASS
Failure Detected: YES
Error Logged: YES (WARN level)
User Notified: YES
Retryable: NO
Message Status: failed_permanentPrueba #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 messagePrueba #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"
50Salida 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 messagesPrueba 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_secondsexcedadrain_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 productionIncrease 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 restartModo 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 trueCasos 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
| Problema | Título | Severidad | Relación |
|---|---|---|---|
| #29125 | Gateway crash silently drops user message from history | P0 | Problema primario — abordado en esta guía |
| #29126 | Plugin/channel delivery failures are silent and unrecoverable | P0 | Problema primario — abordado en esta guía |
| #29127 | Abort does not prevent recovery-path re-delivery of partial reply | P0 | Problema primario — abordado en esta guía |
| #29128 | Delivery-recovery replays already-delivered messages after restart | P0 | Problema primario — abordado en esta guía |
| #29085 | fix(delivery-queue): Telegram 'bot was kicked' | P2 | Correcció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 error | Descripción | Problemas conectados |
|---|---|---|
DLV_001 | Fallo de escritura de persistencia | #29125 |
DLV_002 | Acknowledgement prematuro | #29125 |
DLV_003 | Fallo de entrega silencioso | #29126 |
DLV_004 | Re-entrega de recuperación | #29127, #29128 |
DLV_005 | Incompatibilidad de estado al iniciar | #29128 |
PLG_001 | Error de plugin silenciado | #29126 |
SHT_001 | Pé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)