Skip to content

fix(whatsapp): drain reconnect queue after WhatsApp reconnects (#30806)#46299

Merged
mcaxtr merged 4 commits into
openclaw:mainfrom
manuel-claw:fix/whatsapp-reconnect-drain
Apr 9, 2026
Merged

fix(whatsapp): drain reconnect queue after WhatsApp reconnects (#30806)#46299
mcaxtr merged 4 commits into
openclaw:mainfrom
manuel-claw:fix/whatsapp-reconnect-drain

Conversation

@manuel-claw

@manuel-claw manuel-claw commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

Problem

When WhatsApp briefly disconnects (5–11 seconds, status 499/428/408), outbound messages fail with "No active WhatsApp Web listener" and are persisted to the delivery queue on disk. However, recoverPendingDeliveries() only runs at gateway startup — so those messages sit in the queue until the next restart, often hours later.

Fixes #30806. Also mitigates the race condition in #30177 (delivery recovery fires before WhatsApp listener is ready on startup).

Root Cause

sendMessageWhatsApp() → requireActiveWebListener()
  → throws "No active WhatsApp Web listener (account: X)"
→ failDelivery(retryCount=1) → 25s backoff
→ recoverPendingDeliveries() runs only at startup
   ↳ messages wait until next gateway restart

Solution

Add drainReconnectQueue() to delivery-queue.ts and call it from monitor.ts directly after setActiveWebListener() succeeds (i.e., after the Baileys socket reaches connection: "open").

The drain resets the backoff on queued "no listener" entries and immediately re-runs delivery — so messages are recovered within milliseconds of reconnect, not hours.

Changes

  • src/infra/outbound/delivery-queue.ts — adds drainReconnectQueue():
    • Filters entries that failed with "No active WhatsApp Web listener" for the reconnected account
    • Expires entries older than 5 minutes (moves to failed/) to avoid delivering stale messages
    • Resets retryCount and lastAttemptAt to bypass exponential backoff
    • Calls recoverPendingDeliveries() with a 30s timeout
    • In-memory mutex per account prevents concurrent drains during rapid reconnect flapping
    • Injectable deliver parameter for unit testing
  • extensions/whatsapp/src/auto-reply/monitor.ts — calls drainReconnectQueue() as fire-and-forget after setActiveWebListener()
  • src/infra/outbound/delivery-queue.reconnect-drain.test.ts — 6 unit tests covering: happy path, other-account filtering, TTL expiry, delivery failure resilience, max-retries skip, concurrency guard

What does NOT change

  • No new dependencies
  • No breaking API changes
  • Fully backward-compatible with existing crash-recovery flow
  • The drain is void + .catch() — a failure never disrupts the reconnect flow

Test Results

✓ src/infra/outbound/delivery-queue.reconnect-drain.test.ts (6 tests) 17ms
Test Files: 955 passed (956 total — 1 pre-existing unrelated failure in store.redirect.test.ts)
Tests: 7982 passed | 1 failed (pre-existing) | 3 skipped

The one failing test (media/store.redirect.test.ts) is a pre-existing JPEG size assertion unrelated to this change.

Notes for Reviewers

  • NO_LISTENER_ERROR_RE lives in the generic delivery-queue.ts. This is a minor abstraction tradeoff — the function is exclusively called from WhatsApp code, but an alternative design would pass a matchError predicate from monitor.ts. Happy to refactor if preferred.
  • The dynamic import('../outbound/deliver.js') avoids a circular dependency at module load time.
  • The drainInProgress map is process-local (not persisted). A rapid flap that produces multiple reconnects will skip the second drain — the entries remain in the queue and will be picked up by the next successful drain.

AI-Assisted

  • Built with Claude Code (Opus 4.6) + reviewed by Gemini 3.1 Pro
  • Fully tested locally (pnpm build && pnpm check && pnpm test)
  • Author understands all changes
  • No unresolved bot conversations

AI-Assisted Disclosure

  • AI-assisted (Claude Sonnet 4.6 via OpenClaw)
  • Fully tested — all 6 reconnect-drain unit tests pass
  • Bot review comments addressed (Codex P1 + Greptile suggestions implemented)
  • Author (manuel-claw / Manuel) understands the code and can explain every change

@openclaw-barnacle openclaw-barnacle Bot added channel: whatsapp-web Channel integration: whatsapp-web size: M labels Mar 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ea9d0a0bb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/infra/outbound/delivery-queue.ts Outdated
Comment on lines +425 to +426
// Per-account drain lock to prevent concurrent drains on rapid reconnects
const drainInProgress = new Map<string, boolean>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize reconnect drains before global recovery

drainReconnectQueue only guards concurrency per accountId, but _drainReconnectQueueCore then calls recoverPendingDeliveries, which scans and delivers the entire queue (not just that account). If two WhatsApp accounts reconnect close together (or one reconnect drain overlaps the startup recovery in src/gateway/server.impl.ts), both workers can load the same pending entry and call deliver before either ackDelivery runs, resulting in duplicate outbound sends. The lock needs to be global for queue recovery, or recovery must be limited to the selected entry IDs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the follow-up commit. Replaced recoverPendingDeliveries() with a targeted delivery loop that only processes the eligible entries scoped to opts.accountId. This eliminates the cross-account delivery race and the startup-recovery overlap entirely — no global serialization needed since we never touch other accounts' entries.

@greptile-apps

greptile-apps Bot commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a long-standing gap where outbound WhatsApp messages that failed with "No active WhatsApp Web listener" during a brief disconnect would sit in the delivery queue until the next gateway restart. It adds drainReconnectQueue to delivery-queue.ts and calls it from monitor.ts immediately after setActiveWebListener succeeds, recovering queued messages within milliseconds of reconnect.

Key observations:

  • The per-account concurrency guard (drainInProgress map), TTL expiry (5 min), backoff reset, and atomic file writes are all well-implemented.
  • Multi-account scope issue: _drainReconnectQueueCore uses recoverPendingDeliveries as its delivery mechanism, which loads and retries all pending entries — not just those belonging to the reconnected account. In a multi-account WhatsApp setup, reconnect flapping on account A can trigger delivery attempts for account B's entries (if their backoff has expired), incrementing their retryCount each time. This could exhaust MAX_RETRIES for account B's messages before account B reconnects.
  • The resolveQueueDir function is unnecessarily exported; it is only called within the same file and the new tests construct paths manually without importing it.
  • The fire-and-forget integration in monitor.ts is clean and the .catch() correctly prevents drain errors from disrupting the reconnect loop.
  • Test coverage is solid across all six modeled scenarios.

Confidence Score: 3/5

  • Safe to merge for single-account WhatsApp setups; multi-account deployments carry a real risk of retry-count exhaustion due to the unscoped recoverPendingDeliveries call.
  • The overall design is sound and well-tested, and the fire-and-forget integration is safe by construction. The score is reduced because recoverPendingDeliveries inside _drainReconnectQueueCore processes all pending entries across all accounts and channels — in multi-account deployments, repeated reconnect flapping on one account can burn through MAX_RETRIES for another account's queued messages before that account has had a chance to reconnect, causing permanent message loss. This is a logic correctness issue, not just a style concern.
  • Pay close attention to src/infra/outbound/delivery-queue.ts, specifically the recoverPendingDeliveries call at the end of _drainReconnectQueueCore (lines 515–521).
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/infra/outbound/delivery-queue.ts
Line: 515-521

Comment:
**`recoverPendingDeliveries` is not scoped to the draining account**

After resetting the backoff for entries belonging to `opts.accountId`, `recoverPendingDeliveries` is called with no account filter — it processes every pending entry across all accounts and channels.

In a multi-account WhatsApp setup this creates a correctness hazard: reconnecting account A triggers delivery attempts for account B's queued entries (if their backoff period has already expired). Those attempts fail with `"No active WhatsApp Web listener (account: B)"`, which calls `failDelivery` and increments account B's `retryCount`. If account A flaps rapidly (multiple reconnects), each reconnect fires another full recovery run, and account B's entries can exhaust `MAX_RETRIES` and be moved to `failed/` before account B ever gets a chance to reconnect.

A targeted fix would be to filter `recoverPendingDeliveries`'s candidate list to only the reset entry IDs, or to inline the delivery loop rather than delegating to the full recovery function:

```ts
// Instead of a full recoverPendingDeliveries call, only retry the entries
// that were just reset for this account.
for (const entry of eligible) {
  try {
    await deliver({ cfg: opts.cfg, ...entry, skipQueue: true });
    await ackDelivery(entry.id, opts.stateDir);
  } catch (err) {
    const errMsg = err instanceof Error ? err.message : String(err);
    if (isPermanentDeliveryError(errMsg)) {
      await moveToFailed(entry.id, opts.stateDir).catch(() => {});
    } else {
      await failDelivery(entry.id, errMsg, opts.stateDir).catch(() => {});
    }
  }
}
```

If delegating to `recoverPendingDeliveries` is preferred for its permanent-error and max-retries handling, consider adding a per-account or per-entry-id inclusion filter to that function so the drain stays scoped.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/infra/outbound/delivery-queue.ts
Line: 56-59

Comment:
**Unnecessary export of `resolveQueueDir`**

`resolveQueueDir` is called only from within this same file (`_drainReconnectQueueCore` at line 492). The new test file constructs the queue path manually with `path.join(tmpDir, "delivery-queue")` rather than importing this function, so the export isn't consumed externally. Exporting it unnecessarily widens the module's public API surface.

```suggestion
function resolveQueueDir(stateDir?: string): string {
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 7ea9d0a

Comment thread src/infra/outbound/delivery-queue.ts Outdated
Comment on lines +515 to +521
await recoverPendingDeliveries({
deliver,
cfg: opts.cfg,
log: opts.log,
stateDir: opts.stateDir,
maxRecoveryMs: 30_000,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recoverPendingDeliveries is not scoped to the draining account

After resetting the backoff for entries belonging to opts.accountId, recoverPendingDeliveries is called with no account filter — it processes every pending entry across all accounts and channels.

In a multi-account WhatsApp setup this creates a correctness hazard: reconnecting account A triggers delivery attempts for account B's queued entries (if their backoff period has already expired). Those attempts fail with "No active WhatsApp Web listener (account: B)", which calls failDelivery and increments account B's retryCount. If account A flaps rapidly (multiple reconnects), each reconnect fires another full recovery run, and account B's entries can exhaust MAX_RETRIES and be moved to failed/ before account B ever gets a chance to reconnect.

A targeted fix would be to filter recoverPendingDeliveries's candidate list to only the reset entry IDs, or to inline the delivery loop rather than delegating to the full recovery function:

// Instead of a full recoverPendingDeliveries call, only retry the entries
// that were just reset for this account.
for (const entry of eligible) {
  try {
    await deliver({ cfg: opts.cfg, ...entry, skipQueue: true });
    await ackDelivery(entry.id, opts.stateDir);
  } catch (err) {
    const errMsg = err instanceof Error ? err.message : String(err);
    if (isPermanentDeliveryError(errMsg)) {
      await moveToFailed(entry.id, opts.stateDir).catch(() => {});
    } else {
      await failDelivery(entry.id, errMsg, opts.stateDir).catch(() => {});
    }
  }
}

If delegating to recoverPendingDeliveries is preferred for its permanent-error and max-retries handling, consider adding a per-account or per-entry-id inclusion filter to that function so the drain stays scoped.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/delivery-queue.ts
Line: 515-521

Comment:
**`recoverPendingDeliveries` is not scoped to the draining account**

After resetting the backoff for entries belonging to `opts.accountId`, `recoverPendingDeliveries` is called with no account filter — it processes every pending entry across all accounts and channels.

In a multi-account WhatsApp setup this creates a correctness hazard: reconnecting account A triggers delivery attempts for account B's queued entries (if their backoff period has already expired). Those attempts fail with `"No active WhatsApp Web listener (account: B)"`, which calls `failDelivery` and increments account B's `retryCount`. If account A flaps rapidly (multiple reconnects), each reconnect fires another full recovery run, and account B's entries can exhaust `MAX_RETRIES` and be moved to `failed/` before account B ever gets a chance to reconnect.

A targeted fix would be to filter `recoverPendingDeliveries`'s candidate list to only the reset entry IDs, or to inline the delivery loop rather than delegating to the full recovery function:

```ts
// Instead of a full recoverPendingDeliveries call, only retry the entries
// that were just reset for this account.
for (const entry of eligible) {
  try {
    await deliver({ cfg: opts.cfg, ...entry, skipQueue: true });
    await ackDelivery(entry.id, opts.stateDir);
  } catch (err) {
    const errMsg = err instanceof Error ? err.message : String(err);
    if (isPermanentDeliveryError(errMsg)) {
      await moveToFailed(entry.id, opts.stateDir).catch(() => {});
    } else {
      await failDelivery(entry.id, errMsg, opts.stateDir).catch(() => {});
    }
  }
}
```

If delegating to `recoverPendingDeliveries` is preferred for its permanent-error and max-retries handling, consider adding a per-account or per-entry-id inclusion filter to that function so the drain stays scoped.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The recoverPendingDeliveries call has been replaced with the targeted loop you suggested — delivering only the eligible entries for opts.accountId. Each entry is delivered with skipQueue: true, then acked or failed individually with permanent-error handling.

Comment thread src/infra/outbound/delivery-queue.ts Outdated
Comment on lines 56 to 59
export function resolveQueueDir(stateDir?: string): string {
const base = stateDir ?? resolveStateDir();
return path.join(base, QUEUE_DIRNAME);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary export of resolveQueueDir

resolveQueueDir is called only from within this same file (_drainReconnectQueueCore at line 492). The new test file constructs the queue path manually with path.join(tmpDir, "delivery-queue") rather than importing this function, so the export isn't consumed externally. Exporting it unnecessarily widens the module's public API surface.

Suggested change
export function resolveQueueDir(stateDir?: string): string {
const base = stateDir ?? resolveStateDir();
return path.join(base, QUEUE_DIRNAME);
}
function resolveQueueDir(stateDir?: string): string {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/infra/outbound/delivery-queue.ts
Line: 56-59

Comment:
**Unnecessary export of `resolveQueueDir`**

`resolveQueueDir` is called only from within this same file (`_drainReconnectQueueCore` at line 492). The new test file constructs the queue path manually with `path.join(tmpDir, "delivery-queue")` rather than importing this function, so the export isn't consumed externally. Exporting it unnecessarily widens the module's public API surface.

```suggestion
function resolveQueueDir(stateDir?: string): string {
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. resolveQueueDir is no longer exported — the export keyword has been removed as suggested.

@manuel-claw
manuel-claw force-pushed the fix/whatsapp-reconnect-drain branch from 7ea9d0a to 979b669 Compare March 22, 2026 02:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 979b669a39

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/infra/outbound/delivery-queue.ts Outdated
cfg: opts.cfg,
log: opts.log,
stateDir: opts.stateDir,
maxRecoveryMs: 30_000,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove hard 30s cutoff from reconnect queue drain

The reconnect drain always calls recoverPendingDeliveries with maxRecoveryMs: 30_000, so a larger backlog can hit the deadline and leave remaining entries in delivery-queue/ with no follow-up pass. Because this drain is only triggered on reconnect, those untouched messages can sit indefinitely until another reconnect or a process restart, which reintroduces the “stuck until restart” behavior for high-volume disconnect windows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. As part of a previous commit, recoverPendingDeliveries is no longer called in the reconnect drain. It was replaced with a targeted per-entry delivery loop, so the hard 30s cutoff is no longer applicable here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91cf5be98f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/infra/outbound/delivery-queue.ts Outdated
Comment on lines +502 to +506
await fs.promises.writeFile(tmp, JSON.stringify(reset, null, 2), {
encoding: "utf-8",
mode: 0o600,
});
await fs.promises.rename(tmp, filePath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate entry before rewriting reconnect-reset file

drainReconnectQueue works from a one-time loadPendingDeliveries snapshot, then unconditionally rewrites each eligible {id}.json file here. Because startup recovery is still launched concurrently in src/gateway/server.impl.ts (recoverPendingDeliveries runs in a detached async task), another worker can deliver+ack the same entry between snapshot and this rename; this code will recreate the deleted queue file from stale data and then deliver it again in the later loop, causing duplicate outbound sends. Add a per-entry existence/ownership check (or shared lock) before rewriting/sending to avoid resurrecting already-acked entries.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The race you describe is real — between the loadPendingDeliveries snapshot and the atomic tmp → rename write, a concurrent recoverPendingDeliveries run could have already acked and deleted that {id}.json. Renaming our tmp file into the now-absent path resurrects a stale entry and causes a duplicate send.

The drainInProgress guard prevents two drain calls from racing each other, but doesn't cover the startup-recovery path running concurrently.

Fix: add a per-entry existence check before the rewrite, and skip if the file is already gone:

try {
  await fs.promises.access(filePath);
} catch {
  opts.log.info(`reconnect drain: entry ${entry.id} already acked by concurrent recovery, skipping`);
  continue;
}
// proceed with tmp write + rename

Also wrap ackDelivery in the delivery loop to tolerate ENOENT (the moveToFailed paths already use .catch(() => {}) — ack should be the same). Will address in a follow-up commit.

Comment thread src/infra/outbound/delivery-queue.ts Outdated
Comment on lines +476 to +477
for (const entry of expired) {
await moveToFailed(entry.id, opts.stateDir);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle missing expired entries without aborting the drain

This moveToFailed call is not guarded, so an ENOENT (for example, when another concurrent recovery already moved/acked the same entry after the initial snapshot) throws out of _drainReconnectQueueCore and aborts the whole reconnect drain. Since the caller in monitor.ts only logs and does not retry immediately, one disappearing expired entry can prevent all remaining eligible messages in that batch from being replayed until a later reconnect/restart.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The moveToFailed call in the expired-entries loop has no error guard, so an ENOENT (entry already moved or acked by a concurrent recovery) throws out of _drainReconnectQueueCore and kills the rest of the drain — all remaining eligible entries in that batch are silently dropped until the next reconnect.

The delivery loop already uses .catch(() => {}) on its moveToFailed calls, so the fix is to apply the same pattern to the expired loop:

for (const entry of expired) {
  await moveToFailed(entry.id, opts.stateDir).catch((err) => {
    if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
    // Already gone — another recovery handled it
  });
  opts.log.warn(`reconnect drain: expired entry ${entry.id} (TTL/max-retries)`);
}

This makes the drain resilient to disappearing entries without masking real I/O errors. Will include in the same follow-up commit as the P1 fix.

@manuel-claw

Copy link
Copy Markdown
Contributor Author

Pushed commit a447c52 with the P1 + P2 fixes. Summary:

P1 (race condition): Added fs.promises.access(filePath) check before the tmp-write+rename in the eligible-entries loop. If the file is already gone (acked by concurrent recoverPendingDeliveries), we log and continue — no stale resurrection, no duplicate send.

P2 (ENOENT abort): Wrapped moveToFailed in the expired-entries loop with .catch() that swallows ENOENT but rethrows other errors. One missing file no longer aborts the rest of the drain.

Both fixes are in src/infra/outbound/delivery-queue.ts only. TypeScript compiles clean (pnpm tsc --noEmit), existing reconnect-drain tests pass. Pre-commit lint baseline mismatch is pre-existing on this branch (introduced in 91cf5be), unrelated to our change.

@manuel-claw
manuel-claw force-pushed the fix/whatsapp-reconnect-drain branch from a447c52 to 647d020 Compare March 23, 2026 00:13
@manuel-claw

Copy link
Copy Markdown
Contributor Author

Addressed Gemini's review on a447c52 — amended and force-pushed as 647d020:

P1 (TOCTOU blocker — fixed): Removed the fs.promises.access guard entirely (it only shrank the race window). ENOENT is now caught directly on fs.promises.rename with tmp cleanup on failure — the only atomic point where we can safely detect a concurrent deletion:

try {
  await fs.promises.rename(tmp, filePath);
} catch (err: unknown) {
  await fs.promises.unlink(tmp).catch(() => {});
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
    opts.log.info(`reconnect drain: entry ${entry.id} already acked by concurrent recovery, skipping`);
    continue;
  }
  throw err;
}

P2 (double-log — fixed): Converted .catch() to try/catch with continue, so the warn log only fires when moveToFailed actually succeeds — not when the entry was already gone.

TypeScript compiles clean, reconnect-drain tests pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 647d020d7d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/infra/outbound/delivery-queue.ts Outdated
Comment on lines +538 to +540
for (const entry of eligible) {
try {
await deliver({ cfg: opts.cfg, ...entry, skipQueue: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip ENOENT entries in the reconnect send pass

When rename(tmp, filePath) hits ENOENT, the code logs that the entry was already acked and continues, but the entry remains in the original eligible array and is still sent in this later loop. In the overlap case with startup recovery (src/gateway/server.impl.ts runs recoverPendingDeliveries concurrently), that path can re-send messages that were already delivered/acked by the other worker. Fresh evidence in this revision is that ENOENT handling was added, but eligible is never pruned before this deliver loop, so the skipped entry is still delivered.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this is a real bug. The continue in the rename-ENOENT branch skips the writeFile/rename but does not remove the entry from the eligible slice, so the subsequent deliver loop still sends it, creating a double-delivery risk when startup recoverPendingDeliveries has already handled the entry concurrently.

Fix: collect the IDs that hit ENOENT during the reset pass into a Set, then filter eligible before entering the deliver loop:

const skippedIds = new Set<string>();

for (const entry of eligible) {
  // ... writeFile + rename ...
  try {
    await fs.promises.rename(tmp, filePath);
  } catch (err: unknown) {
    await fs.promises.unlink(tmp).catch(() => {});
    if ((err as NodeJS.ErrnoException).code === "ENOENT") {
      opts.log.info(`reconnect drain: entry ${entry.id} already acked by concurrent recovery, skipping`);
      skippedIds.add(entry.id);
      continue;
    }
    throw err;
  }
}

// Only deliver entries that were successfully reset
for (const entry of eligible.filter(e => !skippedIds.has(e.id))) {
  await deliver({ cfg: opts.cfg, ...entry, skipQueue: true });
}

Will push this fix.

@mcaxtr
mcaxtr force-pushed the fix/whatsapp-reconnect-drain branch 2 times, most recently from eaf00fc to 66542f6 Compare April 9, 2026 05:16
@mcaxtr mcaxtr self-assigned this Apr 9, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66542f6dd0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +223 to +224
await deliver(buildRecoveryDeliverParams(entry, opts.cfg));
await ackDelivery(entry.id, opts.stateDir);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate queue entry before reconnect delivery

drainReconnectQueue sends every item from the initial loadPendingDeliveries snapshot without confirming the entry is still pending, so it can race with startup recovery (recoverPendingDeliveries in src/gateway/server.impl.ts) and deliver the same message twice when the other worker acks between snapshot and send. In that overlap, ackDelivery treats missing .json files as success, so duplicates are not detected. Fresh evidence in this revision is that the reconnect path now iterates matchingEntries directly and calls deliver(...) without any per-entry file revalidation step.

Useful? React with 👍 / 👎.

@mcaxtr
mcaxtr force-pushed the fix/whatsapp-reconnect-drain branch 3 times, most recently from 011824a to cc0e0f2 Compare April 9, 2026 05:29
manuel-claw and others added 4 commits April 9, 2026 02:30
…law#30806)

When WhatsApp briefly disconnects (5-11s), outbound messages fail with
'No active WhatsApp Web listener' and are persisted to the disk queue.
However, recoverPendingDeliveries() only ran at gateway startup, causing
messages to wait for the next restart.

Fix: call drainReconnectQueue() immediately after WhatsApp reconnects in
monitor.ts. Messages queued during the disconnect window are re-delivered
within seconds.

Also mitigates the race condition in openclaw#30177.

Fixes openclaw#30806
…xport

Address bot review feedback (Codex + Greptile):
- Replace recoverPendingDeliveries() with targeted per-entry delivery loop
  scoped to the reconnecting account only. Prevents cross-account interference
  in multi-account setups and duplicate delivery races with startup recovery.
- Remove export from resolveQueueDir() — only used internally, no external
  consumers in tests or plugin-sdk.

All 6 reconnect-drain tests pass.
@mcaxtr
mcaxtr force-pushed the fix/whatsapp-reconnect-drain branch from cc0e0f2 to 5ce7634 Compare April 9, 2026 05:32
@mcaxtr
mcaxtr merged commit e30d0cf into openclaw:main Apr 9, 2026
3 checks passed
@mcaxtr

mcaxtr commented Apr 9, 2026

Copy link
Copy Markdown
Member

Merged via squash.

Thanks @manuel-claw!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ce763406e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +219 to +221
if (!claimRecoveryEntry(entry.id)) {
opts.log.info(`WhatsApp reconnect drain: entry ${entry.id} is already being recovered`);
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-attempt entries skipped by concurrent recovery

When drainReconnectQueue hits an entry already claimed by startup recovery, it logs and continues without revisiting that ID in the same drain pass. If the startup attempt later fails transiently (for example, during listener handoff/race windows), that entry stays queued but this reconnect-triggered drain has already moved on, so the message waits for a later reconnect or process restart instead of being recovered immediately. Consider re-queuing skipped IDs for a follow-up pass (or reloading pending entries once claims are released) so the drain does not silently drop recovery opportunities.

Useful? React with 👍 / 👎.

lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
…law#30806) (openclaw#46299)

Merged via squash.

Prepared head SHA: 5ce7634
Co-authored-by: manuel-claw <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…law#30806) (openclaw#46299)

Merged via squash.

Prepared head SHA: 5ce7634
Co-authored-by: manuel-claw <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…law#30806) (openclaw#46299)

Merged via squash.

Prepared head SHA: 5ce7634
Co-authored-by: manuel-claw <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
…law#30806) (openclaw#46299)

Merged via squash.

Prepared head SHA: 5ce7634
Co-authored-by: manuel-claw <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
…law#30806) (openclaw#46299)

Merged via squash.

Prepared head SHA: 5ce7634
Co-authored-by: manuel-claw <[email protected]>
Co-authored-by: mcaxtr <[email protected]>
Reviewed-by: @mcaxtr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: whatsapp-web Channel integration: whatsapp-web size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Outbound message queue for WhatsApp disconnects

2 participants