Skip to content

fix(telegram): queue messages received while Claude is busy instead of dropping them - #250

Open
hoangqnguyen wants to merge 1 commit into
moazbuilds:masterfrom
hoangqnguyen:fix/telegram-queue-when-busy
Open

fix(telegram): queue messages received while Claude is busy instead of dropping them#250
hoangqnguyen wants to merge 1 commit into
moazbuilds:masterfrom
hoangqnguyen:fix/telegram-queue-when-busy

Conversation

@hoangqnguyen

Copy link
Copy Markdown
Contributor

Problem

When a Telegram message arrives while a main-queue run is already in flight, handleMessage() replies "Claude is busy — try again in a moment" and returns early — silently dropping the message. The user has to notice the reply and manually resend, and any message they'd already typed is lost.

// src/commands/telegram.ts
if (busy) {
  await sendMessage(config.token, chatId, "Claude is busy — try again in a moment, ...", threadId);
  return;   // <-- message discarded here
} else {
  result = await runUserMessage("telegram", prefixedPrompt, sessionKey, ...);
  ...
}

Why the queue was already there

The runner already serializes work per thread: runUserMessage() routes through enqueue() in src/runner.ts, keyed by sessionKey, so messages from the same chat run in order:

function enqueue<T>(fn, threadId) {
  if (threadId) {
    const current = threadQueues.get(threadId) ?? Promise.resolve();
    const task = current.then(fn, fn);
    threadQueues.set(threadId, task.then(() => {}, () => {}));
    return task;
  }
  ...
}

The busy branch was short-circuiting before ever reaching that queue.

Fix

Remove the early return so a busy-time message falls through into the existing per-thread queue and runs after the current task finishes.

  • The user still gets an immediate heads-up that their message is queued (plus a pointer to /fork and /kill), so nothing looks silently swallowed.
  • The typing indicator is already started before this point (setInterval(... sendTyping ...), "Keep typing indicator alive while queued/running") and stays alive across the queued wait, so the chat no longer looks frozen.
  • No new concurrency: per-thread serialization is unchanged. This only stops dropping the enqueued work — messages from one chat still run strictly in order.

Discord (src/commands/discord.ts) has no such busy-check and already flows straight into the queue, so this brings Telegram in line with Discord's behavior.

Testing

  • bunx tsc --noEmit passes clean.
  • Manual: sending two messages back-to-back on Telegram now queues the second and answers it after the first, instead of dropping it.
  • bun test: the only failures are pre-existing sessionFiles/sessions tests that assume POSIX path formats and fail on a Windows dev box — unrelated to this change (there are no Telegram tests, and this diff only touches telegram.ts).

Version

Bumped .claude-plugin/plugin.json and .claude-plugin/marketplace.json to 1.0.41 per CONTRIBUTING (shipped src/ change).

…f dropping them

When a Telegram message arrived while a main-queue run was in flight,
handleMessage() replied "Claude is busy — try again in a moment" and
returned early, silently discarding the message. Users had to notice the
reply and manually resend.

The runner already serializes work per-thread: runUserMessage() routes
through enqueue(), keyed by sessionKey, so messages from the same chat run
in order. The busy branch was short-circuiting before ever reaching that
queue.

Remove the early return so a busy-time message falls through into the
existing per-thread queue and runs after the current task finishes. The
user still gets an immediate heads-up that their message is queued (and a
pointer to /fork and /kill), and the typing indicator already stays alive
across the queued wait, so the chat no longer looks frozen.

No new concurrency: per-thread serialization is unchanged; this only stops
dropping the enqueued work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@TerrysPOV TerrysPOV left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overlapping PRs: #238 (feat/telegram-message-queue) implements the same outcome — removing the Telegram busy-rejection so mid-run messages queue instead of being dropped. #238 is stacked on #237 and is currently CHANGES_REQUESTED; this PR is a simpler standalone version of the same fix (it just removes the early return and relies on the existing per-thread enqueue, without #237's per-thread busy tracking). The two are mutually exclusive — one should be picked and the other closed.

Vision: ALIGNED — silently dropping a user's message is a real defect, and queueing it through the existing per-thread serialization (with a heads-up + live typing indicator) is the right fix.

Code review

The mechanical change is clean: after removing the early return, the downstream reads (result, streamMsgId, hadToolLines) are all outer-scoped and unconditionally assigned on the queued path, so there's no fall-through bug.

Three things carry over from the same concerns raised on #238, plus the version:

  1. No backpressure on the per-thread queue. Removing the busy-drop removes the only bound on queued work — a chat that sends N messages while Claude is busy now enqueues N full subprocess runs with no cap, dedup, or coalescing. That cuts against the "lightweight / runs on low-spec machines" goal. Worth a queue-depth cap (or an explicit decision to accept it).

// so messages from this chat run in order instead of being silently lost.
// Let the user know it's waiting rather than pretending nothing happened.
await sendMessage(config.token, chatId, "⏳ Claude is busy — your message is queued and will run after the current one finishes. Use /fork for a quick parallel task, or /kill to cancel what's running.", threadId);
}

  1. /kill doesn't cancel a queued message, but the new busy notice points the user to it. killActive() only terminates the in-flight subprocess; a message already queued behind it still runs afterward (the queue chains via .then(fn, fn)). So a user who reads "use /kill to cancel what's running" and hits /kill will still see their queued message execute. Either the wording should clarify it only cancels the current run, or /kill should also drain the pending thread-queue entry.

  2. Stale clock prefix on queued messages. runUserMessage builds the timestamp prefix (prefixUserMessageWithClock) at arrival time, before the queue wait, so a message queued behind a long run reaches Claude with a stale "now". Minor, but same defect as #238.

Also: the version bump is stale — this targets 1.0.41 but master is 1.0.43, which is the only thing making the PR conflict (the telegram.ts change merges clean). Rebase and re-bump both .claude-plugin/*.json to 1.0.44.

Minor: the "queued and will run after the current one finishes" copy is only accurate when the in-flight run is on the same session — the busy check (isMainBusy) is global while the queue is per-thread, so a message for a different chat/thread actually runs immediately in parallel, not "after".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants