Skip to content

Final reply is never delivered: dispatcher.waitForIdle() blocks forever on a beforeDeliver hook, and the lane stays blocked #103684

Description

@BenjaminBrossi

Version: openclaw 2026.6.11 (npm latest)
Runtime: node v24.18.0, Linux 6.12.75 (arm64, Raspberry Pi)
Channel: whatsapp (plugin, @openclaw/whatsapp 2026.6.11)
Agent backend: claude-cli, single agent, single channel

Summary

An agent run completes normally (claude live session turn: … outBytes=2288), the reply
payload is queued on the reply dispatcher, and then nothing is ever sent. No error, no
timeout, no abort. The reply is persisted as pendingFinalDelivery in sessions.json.

Because pendingFinalDelivery is only cleared after a successful delivery, and because a
lane with a pending final delivery does not start new runs, the lane is permanently
blocked
: every subsequent inbound message only gets ack reactions (✅ 💭 ⏳ ⚠️) and no
run is ever dispatched. Recovery requires stopping the gateway and editing sessions.json
by hand.

Where it hangs (instrumented, not inferred)

I added temporary probes to the installed bundles and reproduced this several times. Probe
output, in order, for one hanging delivery:

[PROBE] A: replies=1 before flushPendingCommentaryProgress
[PROBE] B: after flush, suppressDelivery=false reason=
[PROBE] C: sendFinalPayload start idx=0
[PROBE] C1: before tts
[PROBE] C2: before normalizeReplyMediaPayload
[PROBE] C3: before routeReplyToOriginating
[PROBE] N: route skipped shouldRoute=false runtime=false      ← correct: source == origin
[PROBE] S1: before dispatcher.sendFinalReply
[PROBE] S2: sendFinalReply -> true                            ← payload queued on sendChain
[PROBE] Q1: chain link start kind=final
[PROBE] Q2: beforeDeliver=true kind=final
                                                              ← nothing after this. ever.

So:

  • dispatch-DXwxohlw.jssendFinalPayloaddispatcher.sendFinalReply(payload) returns
    true, i.e. the payload is enqueued onto sendChain in createReplyDispatcher
    (reply-dispatcher.types-frqf6trh.js).
  • The next statement is await mirrorTranscriptAfterDispatcherDelivery(...), whose first
    line is await params.dispatcher.waitForIdle(), and waitForIdle: () => sendChain.
  • Inside the queued chain link, await beforeDeliver(normalized, dispatchInfo) never
    returns. Neither [PROBE] Q3 (after beforeDeliver) nor any error appears.

Result: sendChain never settles → waitForIdle() never resolves → sendFinalPayload
never returns → the dispatch never completes → the enqueued payload is never flushed.
This is a deadlock between the dispatcher's idle wait and its own queued send.

I could not identify which beforeDeliver segment blocks. Probes inside
combineBeforeDeliverHooks, buildReplyPayloadSendingBeforeDeliver, and the wrapper
installed by appendBeforeDeliver were never entered, so the blocking function is the
options.beforeDeliver passed at dispatcher construction from a bundle other than
dispatch-DXwxohlw.js. Someone who knows which module builds the channel reply pipeline's
beforeDeliver should be able to find it immediately.

Note that runReplyPayloadSendingHook has a timeout (getModifyingHookTimeoutMs), but the
beforeDeliver composition around it does not, so a hook-adjacent await that never settles
hangs the whole chain.

Impact

  • The reply exists but is never delivered; the user sees only reactions.
  • pendingFinalDelivery is set (it is written before delivery as a restart-recovery net).
  • The lane then refuses to start any further run. Inbound messages are acked with
    reactions and queued (message queued: … queueDepth=7 sessionState=processing), but no
    cli exec is ever issued.
  • main-session-restart-recovery only replays pendingFinalDelivery for the main session,
    so a channel lane never recovers on its own — not even across a gateway restart.
  • On WhatsApp this compounds with unacked inbound: the provider redelivers the same
    message_ids on every reconnect, which reproduces the hang and looks like a channel loop.

Reproduction

  1. Single agent, claude-cli backend, WhatsApp DM lane.
  2. Send a message; let the run take longer than the stall threshold (~20 s).
  3. Observe: run completes in the log, no Sending message, pendingFinalDelivery: true.
  4. Send anything else: reactions only, no cli exec, queueDepth grows.

Not every run hangs — short runs delivered fine (e.g. a 14 s run at 10:52 delivered in
27 ms). The hang correlates with longer runs and with queued follow-up messages, but I have
not isolated the trigger.

Suggested fixes

  1. Do not waitForIdle() on the same chain you just enqueued to in
    mirrorTranscriptAfterDispatcherDelivery. Await the specific delivery, or mirror the
    transcript from the chain's own completion callback.
  2. Time-bound beforeDeliver. The reply-payload hook already has a timeout; the
    composed beforeDeliver should too, and a timeout must fail the link rather than hang it.
  3. Make pendingFinalDelivery non-blocking. A lane that has an undelivered reply should
    still accept new runs, or a recovery pass should flush it (today only the main session
    gets that). As it stands, a single missed delivery bricks the lane until someone edits
    sessions.json with the gateway stopped.

Related

Filed alongside #103599 (reply session initialization conflicted), which drops the
second inbound message while a run is active. Both together make a DM lane look dead:
one loses the message, the other loses the answer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.impact:ux-release-blockerA non-technical user is blocked without terminal, logs, config, or support.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.no-staleExclude from stale automation

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions