Outbound Delivery Queue
The gateway uses a write-ahead delivery queue to survive crashes during outbound message sends. Before any message is dispatched to a channel, the gateway records a queue entry on disk. If the gateway restarts mid-send,recoverPendingDeliveries replays the entry on the
next boot.
This page covers what operators see when the queue subsystem encounters errors, how to
interpret those log entries, and how to troubleshoot stuck or missing entries.
How it works
- Enqueue (write-ahead): Before sending,
deliver.tscallsenqueueDeliveryto write a.jsonentry under~/.openclaw/delivery-queue/<id>.json. If this write fails, delivery still proceeds (best-effort), but awarnlog is emitted — the entry is not durable and will not be retried on crash. - Send: The message is dispatched to the target channel.
- Acknowledge or fail:
- On success:
ackDeliveryremoves the queue entry (cleanup). - On failure:
failDeliveryincrementsretryCountand setslastError. Entries that exceedMAX_RETRIESare moved to afailed/subdirectory on the next recovery scan.
- On success:
- Recovery: At gateway startup,
recoverPendingDeliveriesreads all entries in the queue dir and re-attempts delivery for any entry that has not yet been acked or exceeded the retry limit.
Operator-visible log entries
The followingwarn-level entries are emitted by the delivery queue subsystem. All appear in
the gateway file log and the Control UI Logs tab. They are non-fatal — the gateway continues
operating.
delivery queue write failed; proceeding without write-ahead entry
What it means: The gateway could not write the queue entry before sending. The send will
still be attempted, but if the gateway crashes before the send completes, this message will
not be retried on restart.
When it occurs: Disk full, permission error on
~/.openclaw/delivery-queue/, or the
queue directory does not exist.
Triage:
delivery queue ackDelivery cleanup failed
What it means: The gateway could not remove the queue entry after completing delivery.
There are two distinct cases depending on the path that triggered this log:
- Success path (
ackDeliveryafter the send succeeded): the channel-side send already completed. The stale entry will be re-attempted at the next recovery scan and a duplicate may be dispatched. - Abort path (
ackDeliveryafter an abort signal fires): the send was abandoned before completing. The stale entry will be re-attempted at the next recovery scan; this is a retry of an unsent message, not a duplicate.
.json file was already deleted by a competing
process (uncommon).
Triage:
delivery queue failDelivery update failed
What it means: After a failed send, the gateway could not increment
retryCount on the
queue entry. The entry stays at its current retry count.
- Non-bestEffort send-error path: the next recovery scan re-attempts delivery, and if the send fails again the retryCount still cannot advance — the entry may be retried indefinitely until the filesystem issue is resolved.
- bestEffort partial-failure path: recovery re-attempts with
bestEffort: true; per-payload errors are swallowed and delivery is counted as complete, so the entry is acked on the first recovery scan and not retried indefinitely.
Troubleshooting
Messages are dropped silently (no retry)
If you see"delivery queue write failed", the queue entry was never written. The send was
still attempted (SC1: delivery proceeds regardless of queue write failure), but since no entry
exists, the message cannot be retried if the process crashed before the send completed. Check
disk space and permissions on the queue directory. If the send succeeded, no further action is
needed — the message was delivered without crash-recovery coverage.
Entry stuck at retryCount below MAX_RETRIES
IffailDelivery update failed fires repeatedly, retryCount stops advancing. The entry
stays in the queue indefinitely. Fix the underlying filesystem problem, then restart the
gateway — recoverPendingDeliveries will resume retry-count progression.
Entry in failed/ subdirectory
An entry in ~/.openclaw/delivery-queue/failed/ exceeded the retry limit and was moved
there by recoverPendingDeliveries. It will not be retried automatically. Inspect the
entry’s lastError field to determine the root cause, resolve it, then either re-queue
the delivery manually or accept the drop.
Duplicate message after ack failure (success path only)
IfackDelivery cleanup failed fires on the success path and the gateway restarts before
manual cleanup, the next recovery scan will re-attempt a delivery that already succeeded. This
can result in a duplicate message on the channel. The duplicate is channel-specific: some
channels (iMessage, WhatsApp) silently de-duplicate; others (Slack, Discord) do not.
If the ackDelivery cleanup failed log fired on the abort path (the abort signal fired
before the send completed), recovery will re-attempt an unsent message — not a duplicate.
Clean up stale entries manually:
For developers
This section is for contributors modifyingsrc/infra/outbound/.
DeliveryError
src/infra/outbound/delivery-error.ts exports a typed error class for all queue-layer
faults. Use it instead of new Error(...) when throwing from delivery subsystem code.
DeliveryErrorCode values:
DeliveryError.from(err) is idempotent: if err is already a DeliveryError, it is
returned unchanged (prevents double-wrapping). The caller’s code/queueId are NOT applied
in the idempotent case — this is intentional (preserves the original classification).
failDelivery concurrency
failDelivery is safe to call concurrently on the same queue entry. Concurrent calls are
serialised per entry via an in-process Map<filePath, Promise<void>> chain
(serializeByKey in delivery-queue.ts). Each call enqueues behind the prior one so
retryCount increments are never lost.
This serialisation is in-process only — it does not protect against two separate gateway
processes sharing the same stateDir. Do not run two gateways with the same ~/.openclaw/
without separate stateDir configuration.
Recovery statistics (RecoverySummary)
recoverPendingDeliveries returns a RecoverySummary object. Each counter has distinct
semantics:
failed and permanentlyFailed are distinct counters. An entry counted in failed
remains in the active queue and will be retried. An entry counted in permanentlyFailed
has been moved to failed/ and is done. Monitoring scripts that previously treated failed
as “total non-recovered” should use failed + permanentlyFailed + skippedMaxRetries for
that aggregate.
Extending the catch surface
EveryfailDelivery/ackDelivery call site in deliver.ts uses a non-throwing catch:
deliver.test.ts using queueMocks.<fn>.mockRejectedValueOnce.
Related: Gateway logging · Gateway troubleshooting