Skip to content

Optimize OpenClaw Discord Workers for Async Processing

Ever had a long agent run get cut off just because the Discord listener hit its timeout? It is a pain when your bot is actually doing the work, but the connection closes and the user gets nothing. We are changing how we handle inbound Discord events to make sure your agents can finish their thoughts without being rushed by a watchdog timer.

The goal is to stop Discord listener timeouts from being a failure that users see. We are making inbound Discord turns asynchronous by doing a few things:

  1. The gateway listener accepts and normalizes inbound events quickly.
  2. A Discord run queue stores serialized jobs using the same ordering we use today.
  3. A worker runs the actual agent turn outside the listener’s lifetime.
  4. Replies go back to the right channel or thread once the run is done.

This is the long-term fix for queued Discord runs timing out at channels.discord.eventQueue.listenerTimeout while the agent run itself is still making progress.

We have already started this plan. Here is what is done:

  • Discord listener and run timeouts are now separate settings.
  • Inbound turns are enqueued into src/discord/monitor/inbound-worker.ts.
  • The worker owns the long-running turn instead of the listener.
  • Per-route ordering is still there.
  • We have tests for timeout regressions.

In plain language:

  • The production timeout bug is fixed.
  • Long turns don’t die just because the listener budget expires.
  • The worker architecture is still being finished.
  • We are moving toward a more stable data-only boundary.

What is still missing:

  • DiscordInboundJob still has some live runtime references.
  • Commands like stop, new, and reset aren’t fully worker-native yet.
  • We need better observability for the worker.
  • There is no restart durability yet.

Right now, the full agent turn is stuck to the listener’s lifetime. A few files handle this:

  • src/discord/monitor/listeners.ts sets the timeout.
  • src/discord/monitor/message-handler.ts keeps the run inside that limit.
  • src/discord/monitor/message-handler.process.ts handles media, routing, and replies.
  • The overall runtime manages the execution flow.

This setup has two big problems:

  • Healthy turns get aborted by the listener watchdog.
  • Users see no reply even if the runtime would have finished.

Raising the timeout helps, but it does not change how things fail.

We are keeping this focused. We aren’t doing these things:

  • Redesigning non-Discord channels.
  • Making a generic all-channel worker framework yet.
  • Extracting shared worker abstractions unless it’s obvious.
  • Adding durable crash recovery unless it’s necessary.
  • Changing route selection or ACP policy.

The Discord path still uses some live objects that shouldn’t be in the job payload:

  • Carbon Client.
  • Raw Discord event shapes.
  • In-memory guild history.
  • Thread binding callbacks.
  • Live typing and draft state.

The worker is running in the same process with these objects for now, but we want a clean data-only boundary.

DiscordMessageListener will now just check policies, normalize input into a DiscordInboundJob, enqueue it, and return immediately. It won’t own the LLM turn anymore.

We’re introducing a serializable descriptor. It needs to include:

  • Route identity (agentId, sessionKey, accountId, channel).
  • Delivery identity (channel id, message id, thread id).
  • Sender identity (id, username, tag).
  • Channel context (guild id, thread metadata).
  • Normalized message body (text, media references).
  • Gating decisions (mentions, commands).

The job payload must not have live Carbon objects or closures.

A specific runner in src/discord/monitor/inbound-worker.ts will:

  • Reconstruct context from the job.
  • Load media and metadata.
  • Dispatch the agent turn.
  • Deliver replies.
  • Update status.

We’ll keep the same logic as resolveDiscordRunQueueKey(...). This ensures conversations don’t interleave and channels stay independent.

We’ll have two types:

  • Listener timeout: Short, just for enqueuing.
  • Run timeout: Worker-owned and visible to users.

This stops the accidental link between the listener staying alive and the run being healthy.

Phase 1: Normalization boundary. We’ve started this by extracting buildDiscordInboundJob(...). Next, we need to make it plain data and move dependencies to the worker.

Phase 2: In-memory worker queue. This is done. The listener enqueues jobs and the worker runs them in-process.

Phase 3: Process split. We’ll move delivery and typing behind worker adapters and split processDiscordMessage(...).

Phase 4: Command semantics. We need to make sure stop, new, and reset work with the queue.

Phase 5: Observability. We’ll track queue depth, worker counts, and timing.

Phase 6: Optional durability. Later, we can decide if jobs should survive a restart.

Primary files:

  • src/discord/monitor/listeners.ts
  • src/discord/monitor/message-handler.ts
  • src/discord/monitor/message-handler.preflight.ts
  • src/discord/monitor/message-handler.process.ts
  • src/discord/monitor/status.ts

Worker files:

  • src/discord/monitor/inbound-job.ts
  • src/discord/monitor/inbound-worker.ts
  • src/discord/monitor/inbound-job.test.ts
  • src/discord/monitor/message-handler.queue.test.ts

Next touch points:

  • src/auto-reply/dispatch.ts
  • src/discord/monitor/reply-delivery.ts
  • src/discord/monitor/thread-bindings.ts
  • src/discord/monitor/native-command.ts

We need to make the worker boundary real. You should:

  1. Move live runtime dependencies out of DiscordInboundJob.
  2. Keep those on the worker instance instead.
  3. Reduce jobs to plain data like route info and message snapshots.
  4. Reconstruct the context inside the worker.

This means things like the client, threadBindings, and guildHistories shouldn’t be in the job payload.

Keep the timeout tests in src/discord/monitor/message-handler.queue.test.ts. Add new tests for:

  1. Listener returning after enqueue.
  2. Per-route ordering.
  3. Concurrent channel runs.
  4. Correct reply delivery.
  5. stop command behavior.
  6. Worker failure diagnostics.
  7. ACP-bound channel routing.
  • Risk: Commands drift from current behavior. Mitigation: Fix command plumbing in the same update.
  • Risk: Replies lose context. Mitigation: Make delivery identity a priority in the job data.
  • Risk: Duplicate sends. Mitigation: Keep it in-memory for now or add idempotency.
  • Risk: Code becomes hard to read. Mitigation: Split the message handler into smaller helpers.

The plan is a success when:

  1. Listener timeouts don’t kill healthy turns.
  2. Listener and turn lifetimes are separate.
  3. Ordering is preserved.
  4. ACP-bound channels work correctly.
  5. stop targets the worker run.
  6. Failures are clear and explicit.

Finish these in the next PRs:

  1. Make the job plain-data and move refs to the worker.
  2. Clean up command ownership for stop, new, and reset.
  3. Add worker observability and status.
  4. Decide on durability or document the in-memory boundary.
  • Check out src/discord/monitor/inbound-worker.ts
  • Review src/discord/monitor/inbound-job.ts

AI Setup Assistant

OpenClaw

OpenClaw Expert

Still stuck?

If this page didn't answer your case, ask OpenClaw Expert for step-by-step guidance.