Procesamiento durable de mensajes
Por defecto, un Bot guarda en memoria todo lo que tiene en curso. Si el proceso muere mientras atiende un mensaje, ese mensaje se pierde (salvo que el canal lo vuelva a entregar), y una respuesta que se estaba enviando puede haber salido o no. Para muchos bots eso basta. Cuando no basta, activa la durabilidad:
import { Bot } from '@botmesh/core'
import { PostgresDurability, PostgresStore } from '@botmesh/postgres-store'
const bot = new Bot({
provider,
store: new PostgresStore({ pool }),
durability: {
backend: new PostgresDurability({ pool }),
instanceId: 'bot-1', // estable entre reinicios de este despliegue
outbox: { retries: 5, backoff: { baseMs: 1000, capMs: 60_000 } },
},
})Con durability, el bot lleva dos journals:
- El inbox. Cada mensaje o interacción admitido se anota antes de que corra ningún handler, y se marca como hecho solo cuando sus efectos se asentaron. Tras una caída, el siguiente arranque reprocesa lo que quedó a medias.
- El outbox. Cada envío se anota con una clave de idempotencia antes de llamar al provider, y justo después se guarda el id de mensaje que devolvió el provider. Un envío cuya clave ya está guardada se responde desde el outbox en vez de salir otra vez. Así un reproceso no manda dos veces la misma respuesta. Los envíos que fallan se reintentan con backoff y van a la cola de muertos (DLQ) cuando se les acaban los reintentos.
Sin la clave durability no cambia nada: el bot ejecuta exactamente el mismo código que antes, en memoria y en un solo proceso.
Backends
| Backend | Paquete | Sobrevive a una caída | Varias réplicas |
|---|---|---|---|
MemoryDurability (el de durability: {}) | @botmesh/core | No (solo reintentos, backoff y DLQ) | No |
FileDurability.open(ruta) | @botmesh/core | Sí | No (un archivo de lock rechaza un segundo proceso vivo, y un segundo open() en el mismo proceso hasta close()) |
PostgresDurability({ pool | connectionString, tablePrefix, logger }) | @botmesh/postgres-store | Sí | Sí, con enrutado fijo por chat (ver Varias réplicas) |
RedisDurability({ client | url, prefix }) | @botmesh/redis-store | Sí (con la persistencia de Redis activada) | Sí, con enrutado fijo por chat (ver Varias réplicas) |
eventsDurability({ journal }) | @botmesh/events/durability (privado, solo en el monorepo) | Con un FileEventJournal abierto con { lock: true } o un SharedFileEventJournal | Con un SharedFileEventJournal (procesos en un mismo host; ver Un journal de @botmesh/events) |
PostgreSQL crea una tabla, {tablePrefix}durable, de forma idempotente al arrancar. Redis usa claves bajo {prefix}:{durable:inbox:<scope>} y {prefix}:{durable:outbox:<scope>}. Ninguno toca las tablas ni las claves del store. Con connectionString, PostgreSQL crea (y al parar cierra) su propio pool, y una conexión que el servidor corta mientras está ociosa (reinicio, failover) se informa a logger.error y se sustituye en la siguiente consulta. Un pool inyectado es tuyo: dale un listener pool.on('error', …), porque node-postgres tumba el proceso si nadie escucha.
FileDurability.open(ruta, { appendLog: true }) (o { compactEvery, compactBytes, fsync }) añade cada escritura confirmada a <ruta>.log en vez de reescribir el snapshot, y compacta cada 1000 entradas o 4 MiB; close() pliega el log en el snapshot. Un log sobrante se reproduce siempre al abrir, incluso sin la opción. Un journal que no se puede volver a leer (un snapshot truncado, una entrada del log dañada) se rechaza al abrir con un error que nombra el archivo, en vez de aplicarse a medias: restáuralo de una copia o apártalo. Una última línea del log a medias (una caída a mitad de escritura) se recorta y el resto se reproduce; una línea mala en medio, seguida de más entradas, es corrupción: open() la rechaza con code: 'APPEND_LOG_CORRUPT' (archivo, línea y byte) y deja el archivo como está, porque cortar ahí perdería en silencio todas las entradas posteriores. fsync: true fuerza cada snapshot a disco antes de reemplazar al anterior (y es el valor por defecto de appendLog.fsync); sin él el rename es atómico, pero puede seguir en la caché del sistema si la máquina se queda sin corriente. FileEventJournal y SharedFileEventJournal de @botmesh/events aceptan la misma opción fsync y rechazan igual un log dañado.
Varios bots pueden compartir un backend. Cada bot trabaja en su propia vista del backend, acotada por su namespace (tenantId, environmentId, botId, connectionId; bot.durability.scope es el id derivado). Un bot solo toma, reprocesa y deduplica sus propios registros, y las claves de idempotencia explícitas son por bot: dos bots que encolan recordatorio:1 envían cada uno el suyo. Un backend propio sin scope() no debe compartirse entre bots. Todos los backends pasan la misma suite de conformidad (test-utils/durability-conformance.ts), y puedes escribir el tuyo implementando la pequeña interfaz DurableKv (compare-and-set con versión más dos índices ordenados) y envolviéndola en KvDurability (las dos en @botmesh/core/spi).
El bot arranca y detiene el backend por su cuenta. Usa también un store persistente: los checkpoints de un flow .persistent() viven en el store y el reproceso depende de ellos.
Un journal de @botmesh/events
Una app que ya lleva un journal de @botmesh/events puede usarlo como backend, sin otro store:
import { FileEventJournal } from '@botmesh/events'
import { eventsDurability } from '@botmesh/events/durability'
const journal = await FileEventJournal.open('./data/events.json', { lock: true })
const bot = new Bot({ provider, durability: { backend: eventsDurability({ journal }) } })
// después de bot.stop(): await journal.close()Los registros de la inbox son
EventRecords de ese journal, en el canal'botwa:durable', coneventType'botwa.inbound.message'o'botwa.inbound.interaction'. Ellist()y elreplay()del journal los ven, y el barrido del bot recoge unreplay. UnEventWorkernunca los toma: unclaimsin canal solo toma registros sin canal.El outbox es el del core, guardado como sidecar del journal: el mismo archivo y el mismo snapshot atómico que la inbox.
Un
FileEventJournaldebe abrirse con{ lock: true }. El archivo de lock con el pid impide que un segundo proceso lo abra, y un segundoopen()del mismo archivo en el mismo proceso se rechaza hasta que se cierra el primero.Coste: un
FileEventJournalreescribe el archivo entero en cada escritura, y aquí ese archivo guarda los eventos de la app, los registros de entrada del bot (durante la retención, 7 días por defecto) y el outbox. Cada mensaje entrante y cada envío cuestan unas pocas reescrituras (las escrituras concurrentes comparten una, y los heartbeats de lease solo se escriben con el siguiente cambio). Para algo más que poco tráfico, da al bot un archivo de journal propio, acorta la retención o pasa unoutboxrespaldado por una base de datos; o usaFileDurabilityo un backend de base de datos.Para varias réplicas hace falta un
LeasedEventJournalcompartido, con operaciones atómicas y con fencing.SharedFileEventJournales uno: varios procesos abren el mismo archivo y cada escritura corre bajo un mutex entre procesos (<archivo>.mutex) sobre el archivo actual, así que los claims son exclusivos y los fencing tokens valen entre procesos. El outbox vive en el mismo archivo y también se comparte:tsimport { SharedFileEventJournal } from '@botmesh/events' const journal = await SharedFileEventJournal.open('./data/events.json') const bot = new Bot({ provider, durability: { backend: eventsDurability({ journal }) } })Está pensado para pocos procesos en un mismo host (o un sistema de archivos con rename atómico y creación exclusiva; no NFS). Por defecto cada escritura relee y reescribe el archivo entero; con
appendLog: true(o{ compactEvery, compactBytes, fsync }) cada escritura añade una línea a<archivo>.logcon los registros/estado lateral que cambiaron, y el snapshot se reescribe (conlogSeq) y el log se trunca cada 1000 entradas o 4 MiB. Un fallo entre el snapshot y el truncado es inofensivo (el replay salta las entradas ya incluidas) y una última línea a medias se recorta. Un mutex que dejó un proceso muerto se rompe al momento; un titular vivo refresca su mtime (heartbeat,heartbeatMspor defecto un tercio destaleLockMs), así que solo se da por abandonado un titular atascado trasstaleLockMs(30 s por defecto). Nunca se mata nada (déjalo muy por encima de la escritura más lenta: si tres procesos compiten por romper un mutex ya atascado, dos pueden tenerlo un instante y perderse una escritura; usa un journal de base de datos si eso importa).close()deja terminar las operaciones ya llamadas y rechaza las posteriores. El formato del archivo es el deFileEventJournal, pero no abras un archivo con los dos a la vez (el journal compartido rechaza un archivo que tiene unFileEventJournalvivo conlock, también si ese journal lo abre después: cada operación compartida se rechaza en lugar de perder registros). La regla del mismo chat de Varias réplicas sigue valiendo. Para mucho tráfico, usa otroLeasedEventJournalcon unoutboxcompartido (eventsDurability({ journal, outbox: kv }), con elDurableKvde un backend de Postgres o Redis), o esos backends directamente.
Qué se garantiza
Los envíos son exactamente una vez mientras el proceso vive. Tras una caída también lo son, salvo en una ventana que ningún canal permite cerrar: el proceso muere después de que el provider aceptó un mensaje y antes de que el outbox lo anotara.
| El proceso muere… | Al reiniciar |
|---|---|
| antes de anotar el mensaje entrante | No quedó nada anotado. El mensaje se procesa si el canal lo vuelve a entregar (los webhooks lo hacen; Baileys puede que no). |
| después de anotarlo, antes o durante el handler | El mensaje se reprocesa. Las respuestas que ya salieron se responden desde el outbox; las que faltan se envían. |
| entre "voy a llamar al provider" y anotar "enviado" | Ambiguo. onAmbiguous: 'skip' (por defecto) marca el envío como unknown: nunca se duplica, puede perderse. 'resend' lo envía otra vez: nunca se pierde, puede duplicarse. En ambos casos se emite 'durability:ambiguous'. |
| después de anotar el envío, antes de confirmar el mensaje entrante | El mensaje se reprocesa; todas las respuestas se responden desde el outbox. |
| mientras un flow persistente espera una respuesta | El flow sigue desde su checkpoint. La pregunta pendiente no se repite y el siguiente mensaje se toma como su respuesta. |
| mientras un flow no persistente espera una respuesta | La conversación se perdió (vivía en memoria). La respuesta reprocesada pasa por el enrutado normal como un mensaje nuevo, y su respuesta se envía (ver onDiverged más abajo). Los flows que deban sobrevivir a un reinicio tienen que ser .persistent(). |
| en cada ejecución del mismo mensaje (tumba el proceso) | El mensaje se reprocesa hasta que se cobran inbox.maxAttempts ejecuciones sin terminar; después va a la DLQ ('durability:dead') en vez de tumbar otra vez el siguiente arranque. |
Los handlers son al menos una vez: un mensaje reprocesado vuelve a ejecutar su handler y solo se deduplican sus envíos. Haz idempotentes los demás efectos (un upsert, un ctx.user.set, una escritura en base de datos con clave ctx.ref.id) o dales su propia clave.
Claves de idempotencia
Cada envío lleva una clave:
options.idempotencyKeycuando la pasas:bot.send(chatId, texto, { idempotencyKey: 'factura:42' }).- Si no, mientras se atiende un mensaje entrante:
<id del registro entrante>:<n>, el n-ésimo envío hecho para ese mensaje. Un paso de conversación usa el mensaje que lo alimentó, así que las respuestas de un flow conservan sus claves cuando el flow se reanuda. - Si no, una clave aleatoria. El envío igual se reintenta y sobrevive a una caída, pero una segunda llamada no se reconoce como el mismo envío.
Por la regla 2, un handler durable debe enviar en un orden determinista. Si un reproceso hace otro número de envíos, o los hace en otro orden, las claves dejan de cuadrar: salen envíos de más y los que desaparecieron no se reconocen. Para envíos cuyo orden depende del tiempo (bot.broadcast con concurrency > 1, un Promise.all de respuestas) pasa claves explícitas.
El outbox guarda además una huella de cada envío (chat + contenido, antes de los hooks before('send')). Cuando un reproceso produce otro contenido bajo una clave de la regla 2, el handler tomó otro camino (un flow no persistente perdido en la caída, una condición que cambió). outbox.onDiverged decide: 'send' (por defecto) envía el contenido nuevo con una clave nueva, para que ninguna respuesta se trague en silencio; 'skip' lo responde desde el envío anotado, como antes. En ambos casos lo cuenta metrics().outboxDiverged y se registra un aviso. Elige 'skip' cuando las respuestas no son deterministas (texto generado por IA) y una segunda respuesta distinta sería peor que ninguna. Las claves explícitas nunca se comparan: la clave que pasas nombra el envío.
El provider nunca ve idempotencyKey: el core la quita antes de llamar al provider.
Reintentos, envíos diferidos y la cola de muertos
Un envío que falla se reintenta con backoff (un RateLimitedError espera al menos su retryAfterMs), salvo que el error sea definitivo. Estos errores son definitivos y van directo a la DLQ: UnsupportedContentError, PayloadTooLargeError, OutsideSessionWindowError, RecipientUnreachableError o cualquier error con retryable: false.
ctx.reply y bot.send siguen reintentando en línea hasta outbox.inlineMaxMs (por defecto 15s). Pasado ese tiempo rechazan con SendDeferredError, que lleva la key del outbox. El envío no se pierde: el barrido en segundo plano lo sigue reintentando. Si quien llama no necesita esperar, encola el envío:
const { key } = await bot.enqueue(chatId, 'Tu cita es mañana a las 9', {
idempotencyKey: `recordatorio:${cita.id}`, // un segundo enqueue de esta clave no hace nada
})bot.enqueue ejecuta los hooks before('send') una vez, anota el envío y vuelve enseguida. El barrido lo entrega y después ejecuta after('send') y escribe el registro de mensajes. Dentro de un proceso, los envíos a un chat conservan su orden mientras uno de ellos se reintenta en línea. Un texto que supera el límite del canal se divide después de los hooks, en un registro por parte (key, key#2, …, entregados en ese orden). Una parte nunca adelanta a una anterior: mientras key espera un reintento, key#2 espera detrás (y muere con ella si key va a la DLQ). Esperar detrás de una parte anterior no cuenta como intento, así que cada parte conserva todo su presupuesto de outbox.retries para sus propios fallos. El vínculo entre partes se guarda en el registro de cada parte (OutboxRecord.previousPart), no se deduce de la clave: tus propias claves de idempotencia pueden acabar en #2, #3… y siguen siendo envíos independientes. Un registro anotado cuyo texto supera el límite (escrito por una versión anterior, o con un límite mayor) lo divide el barrido con el mismo divisor: la primera parte sale con la clave del registro y las demás se anotan como key#2, key#3… antes de enviarla, así que un reintento o una segunda pasada no anotan ni envían nada dos veces. Las claves de las partes solo dependen de la clave del registro, del límite del canal y del texto, así que son las mismas en cada replay.
Los mensajes entrantes cuyo procesamiento falla antes de los handlers (por ejemplo, el store está caído) se reintentan con inbox.backoff y van a la DLQ tras inbox.maxAttempts. Un handler que lanza un error no se reintenta: sus efectos ya ocurrieron. El error llega a bot.on('error') y el mensaje se confirma.
Operación
bot.on('durability:dead', ({ kind, id, error }) => alertar(kind, id, error))
bot.on('durability:ambiguous', ({ key, chatId }) => auditar(key, chatId))
const d = bot.durability!
await d.inbox.list({ state: 'dead' }) // la DLQ de entrada
await d.outbox.list({ state: 'dead' }) // la DLQ de salida
await d.replayInbound(id) // procesarlo otra vez
await d.retrySend(key) // enviarlo otra vez (dead, failed o unknown)
await d.outbox.discard(key) // olvidarlo
d.metrics() // { inboundJournaled, inboundDuplicates, inboundReplayed, inboundDead,
// outboxJournaled, outboxDeduped, outboxRetried, outboxDeferred,
// outboxDead, outboxAmbiguous, outboxBypassed, staleLeases, outboxDiverged }- Leases y fencing. Un registro en proceso se presta a
instanceIdduranteinbox.leaseMs(60 s por defecto), y un envío en curso duranteoutbox.leaseMs(60 s por defecto; como mínimo 20 ms, o el bot rechaza la configuración). Un latido extiende ambos mientras corren el handler o la llamada al provider, así que una subida lenta nunca se toma por un envío abandonado. Si un préstamo vence (el proceso murió o se congeló), otra réplica, o esta misma tras reiniciar, toma el registro con un nuevo token de fencing. Entonces se rechaza la confirmación del dueño anterior y se cuenta enstaleLeases. instanceId. Mantenlo estable entre reinicios de un despliegue, para que un proceso reiniciado recupere enseguida sus registros sin terminar en vez de esperar a que venzan sus préstamos. Su primer barrido tras el reinicio solo recupera los préstamos tomados antes de que se recuperara (los de la encarnación muerta, incluida una escritura que llegó tarde), nunca un envío que está haciendo él mismo. Hazlo único por réplica que corra a la vez (el nombre del pod de un StatefulSet, por ejemplo): dos procesos vivos con el mismo id se quitarían los préstamos. En un backend de un solo proceso (memoria, archivo) el valor por defecto es<hostname>/<botId>/<connectionId>. En un backend compartido (Postgres, Redis) se le añade un sufijo por arranque, para que dos procesos del mismo host nunca lo compartan; entonces un reinicio espera a que venzan los préstamos anteriores. Fíjalo explícitamente para evitar esa espera.- Retención. Los registros
doneysentse purgan trasretention(por defecto7d). Mantenla más larga que la ventana de reentrega más larga de tus canales, porque una reentrega solo se reconoce mientras su registro existe. - Cierre ante fallos. Si el journal no puede anotar un mensaje entrante, el mensaje no se despacha. Se emite un
'error'y el dedupe en memoria olvida el mensaje, así que una reentrega se procesa. ConfailOpen: truese despacha sin journal. - Tamaño del contenido. El contenido saliente se anota como JSON, con los Buffers en base64. Un contenido de más de
outbox.maxPayloadBytes(1 MiB por defecto), o uno que no se puede codificar (un stream), se salta el outbox con un aviso. Para archivos grandes, usa media por URL o por ruta. - Media en un reproceso. Ver Media en un reproceso más abajo.
- Ediciones, borradores e indicadores de escritura no se anotan. Las ediciones son idempotentes, y los borradores y los indicadores son pasajeros.
Media en un reproceso
El journal no guarda la media de un mensaje, solo una referencia a ella. Los bytes se descargan bajo demanda con loadMediaOf(ctx). En algunos canales esa referencia es un objeto vivo: Baileys guarda el mensaje original de WhatsApp. Por eso la inbox le pide al provider una referencia serializable y versionada (IProvider.serializeMediaRef) y anota esa. Un mensaje puede reprocesarse tras una caída, como reintento en segundo plano o por un replayInbound de un operador. En todos los casos el provider reconstruye la referencia (IProvider.rehydrateMediaRef) antes de que corra el handler, y la media se descarga igual que con un mensaje en vivo.
| Provider | Qué se anota | Notas |
|---|---|---|
@botmesh/baileys | El contenido de la media (url del CDN, ruta directa, clave y hashes, sin miniatura, pie de foto ni contexto citado) y la clave del mensaje | Si la media caducó en el CDN, se pide de nuevo al teléfono del remitente al descargarla, como con la media en vivo. La media de visualización única no se anota salvo con durableMedia: { viewOnce: true }. |
@botmesh/meta (Cloud API) | El id de la media | Meta guarda la media entrante unos 30 días. |
@botmesh/telegram | El file_id | Solo sirve con el mismo token del bot. |
@botmesh/twilio | La URL de la media | Al leerla solo se aceptan URLs https del origen de la API o de un host twilio.com. |
@botmesh/messenger, @botmesh/instagram | La URL del adjunto | Solo se guardan y se aceptan URLs de las CDN de Meta (fbcdn.net, fbsbx.com, cdninstagram.com; mediaRefHosts reemplaza la lista). Las URLs van firmadas y caducan, así que una reproducción tardía no puede descargar. |
@botmesh/discord | La URL del adjunto | Solo URLs de las CDN de Discord (discordapp.com, discordapp.net; mediaRefHosts reemplaza la lista). Las URLs de adjuntos de Discord caducan a las 24 horas, más o menos. |
| Otros providers | El token tal cual (como antes) | Sus tokens ya son ids o URLs. |
No se anota ninguna credencial: los tokens y las claves se añaden de nuevo al descargar. La referencia guardada se valida al leerla. Un mensaje reprocesado cuya media no se puede reconstruir se procesa igual; solo falla su descarga. loadMediaOf(ctx, { throwOnError: true }) rechaza entonces con MediaUnavailableError (un ProviderError), y su reason es uno de estos:
'not-durable': el provider no serializó la referencia (por ejemplo, visualización única).'provider-mismatch': otro provider escribió el registro.'rehydrate-failed': el valor guardado no es válido, es de una versión desconocida o fue manipulado.
Sin throwOnError, loadMediaOf resuelve undefined, igual que con cualquier descarga fallida.
bot.when(IMAGE, async (ctx) => {
try {
const media = await loadMediaOf(ctx, { throwOnError: true })
// …
} catch (error) {
if (error instanceof MediaUnavailableError) return ctx.reply('Envíame la imagen de nuevo, por favor.')
throw error
}
})La referencia da acceso a la media, igual que el cuerpo del mensaje que se guarda a su lado, así que protege el journal como proteges el store. Con durability.inbox.mediaRefs: false los tokens de media se anotan como antes; un mensaje reprocesado puede entonces no poder descargar su media. Los registros escritos antes de esta función se reprocesan como antes.
Un provider añade los dos métodos opcionales de IProvider. serializeMediaRef(ref) es síncrono y devuelve { v, data } (JSON) o undefined. rehydrateMediaRef(stored, ref) valida stored, lanza un ProviderError si no es válido y no necesita el transporte conectado.
Los trece canales los implementan: Baileys (una clave de mensaje), Meta Cloud, Telegram (file_id), Twilio, Messenger e Instagram (URL de adjunto alojada), Discord, Evolution (la clave del mensaje; el base64 en línea nunca se guarda), WPPConnect (messageId), Gupshup y GoHighLevel (URL http(s) alojada), email (Message-ID + índice del adjunto + digest, más el UID y UIDVALIDITY de IMAP cuando se conocen) y TikTok (coordenadas de la media, o la URL del sticker). Una URL alojada puede caducar en el servidor antes de un replay: el provider rechaza entonces con su error de descarga habitual y el core reporta MediaUnavailableError. Nunca escribas credenciales en data.
Varias réplicas
Los journals se pueden compartir sin riesgo: dos réplicas nunca toman el mismo registro y un dueño caducado no puede completarlo. El resto del bot sigue siendo por proceso: las conversaciones activas, su ask pendiente y la cola por chat viven en la memoria de la réplica que las ejecuta. Por eso:
- Envía cada chat a una sola réplica a la vez (enrutado fijo por id de chat en el balanceador, o reparte los chats entre réplicas). Si dos réplicas reciben mensajes del mismo chat, la respuesta a un
askque una réplica tiene pendiente llega a la otra como un mensaje nuevo. - La conmutación va por préstamos. Cuando una réplica muere o se congela, otra toma sus registros sin terminar en cuanto vencen sus préstamos (
inbox.leaseMs) y los reprocesa. Una réplica viva conserva sus préstamos gracias al latido, tarde lo que tarde su handler. - Da a cada réplica su propio
instanceIdexplícito.
Con coordination (ver Varias réplicas) no hace falta el enrutado fijo: los turnos por chat serializan cada chat entre réplicas y los flujos .persistent() siguen en la réplica que reciba el siguiente mensaje. Los bots con el mismo botId comparten un ámbito de journal, así que cualquier réplica puede reprocesar o entregar un registro. Con una sesión de transporte solo en la líder (providerSession: 'leader', p. ej. Baileys), el barrido del outbox de una réplica en espera nunca llama al transporte (el intento se rechaza con ProviderNotLeaderError y se reintenta); lo entrega la líder.
Con coordinación, un registro que alimentó un paso de conversación se confirma solo cuando aterriza la escritura del checkpoint que guarda su efecto (la marca de espera, o la respuesta de un paso cortado); si un paso suspendido no pudo escribirla, libera el registro y este se reprocesa. Una respuesta guardada apunta a su registro de entrada, así que mantén retention por encima de tu pausa de conversación más larga. Ver Relevo de conversaciones.
Probar la recuperación ante caídas
@botmesh/core/testing exporta crashable(backend), que puede "matar" un bot en una escritura concreta del journal, y CrashableProvider. Abandona el bot caído y arranca uno nuevo sobre el mismo backend y el mismo store, con el mismo instanceId:
import { crashable, CrashableProvider, MockProvider } from '@botmesh/core/testing'
const shared = new MemoryDurability()
const a = crashable(shared)
a.crashBefore('outbox.markSent') // se llamó al provider, el resultado nunca se anota
const botA = new Bot({ provider: new CrashableProvider(a), store, durability: { backend: a, instanceId: 'i1' } })
// … mueve botA hasta que a.crashed …
const botB = new Bot({ provider: new MockProvider(), store, durability: { backend: shared, instanceId: 'i1' } })El repositorio corre una matriz de caídas (test-utils/durability-crash.ts) contra cada backend. Cubre cada punto de caída con las dos políticas de ambigüedad, más puntos aleatorios con semilla y un proceso hijo real al que se mata a mitad de un handler.