Skip to content

Inbound messages are silently dropped when a reply run is active (reply session initialization conflicted) #103599

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/extensions/whatsapp)
Agent backend: claude-cli, activeSessions=1

Summary

If a second inbound message arrives on the same sessionKey while a reply run for
the first one is still active, the second message is dropped. It is never
answered, never queued, and never surfaced to the user — only an ERROR line in the
gateway log:

Failed handling inbound web message:
  Error: reply session initialization conflicted for agent:main:whatsapp:direct:+41…

On WhatsApp the message is also never acked, so the provider re-delivers it on the
next reconnect with the same message_id. That produces a self-sustaining loop:
reconnect → redelivery → run → conflict → drop → no ack → reconnect. From the user's
side the channel looks dead (reactions still arrive, because the reaction path is
independent of the agent, but no text reply ever does).

Root cause

commitReplySessionInitialization() uses optimistic concurrency against
sessions.json. The revision token is the entire serialized session entry:

dist/session-accessor-*.js

function createReplySessionInitializationRevision(entry) {
	return JSON.stringify(entry ?? null);
}

The snapshot is taken in initSessionStateAttempt() (dist/get-reply-*.js:4554) and
committed ~290 lines and several awaits later (:4844). While a run is active the
gateway keeps mutating that same entry — pendingFinalDelivery, token counters,
run-state, lastActivity. Any one of those writes changes the stringified entry and
therefore invalidates the snapshot, even though none of them are relevant to session
initialization
.

The commit is retried exactly once, then it throws:

dist/get-reply-*.js:4875

if (!committed.ok) {
	if (!staleSnapshotRetried) return await initSessionStateAttempt(params, true);
	throw new Error(`reply session initialization conflicted for ${sessionKey}`);
}

The throw propagates to the inbound handler, which logs and discards the message.
There is no requeue and no user-visible error.

Reproduction

  1. Single agent, single channel, claude-cli backend (activeSessions=1).
  2. Send a message that produces a long-running turn (> ~5 s).
  3. While it runs, send a second message to the same DM.
  4. Second message: reactions appear (✅ 💭 ⏳ ⚠️), no reply. Log shows the conflict.

Observed here with two messages ~4 s apart (text, then an image).

Log evidence

08:52:23.878Z  web-inbound          body='wieder da'
08:52:40.334Z  whatsapp/outbound    Sent message 3EB0DD9B…  (27ms)      ← first: ok
08:52:44.914Z  web-inbound          body='<media:image>'
08:52:47.209Z  diagnostic           message dispatch completed: outcome=error
                                    error="Error: reply session initialization conflicted
                                    for agent:main:whatsapp:direct:+41…"
08:52:47.221Z  whatsapp/inbound     Failed handling inbound web message
08:53:15.595Z  whatsapp/outbound    Sent reaction "⚠️"                  ← only signal user gets

After the failure, lastInboundAt froze and messagesHandled stayed at 2 — the
inbound listener stopped accepting further messages until the gateway was restarted.
That secondary effect may deserve its own issue; the drop above is the trigger.

Impact

  • Any user who sends a follow-up before the previous reply lands loses that message.
  • Silent: no error to the user, no retry, no dead-letter.
  • On WhatsApp it escalates into repeated redelivery of the same message_id, which
    looks to operators like a channel/model bug. It cost us roughly an hour of
    misdiagnosis (suspected model pin, config hot-reload, pendingFinalDelivery).

Suggested fixes

  1. Narrow the revision token. Hash only the fields initialization actually depends
    on (sessionId, sessionFile, agentId, reset/fork markers) instead of the whole
    entry. Concurrent run-state writes then stop invalidating the snapshot. This is the
    real fix.
  2. Bounded retry with backoff instead of a single retry. Optimistic concurrency
    should loop, not surrender on the second try. (Applied locally as a stopgap: 8
    attempts, 25 ms → 400 ms exponential backoff + jitter.)
  3. Never drop the message. If the commit ultimately fails, requeue the inbound or
    surface a user-visible error. A dropped message with an unacked provider id is the
    worst of both worlds.

Local workaround in use

Patched dist/get-reply-*.js to retry up to 8 times with exponential backoff. This
survives until the next npm update -g openclaw.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.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.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions