Skip to content

[Fix] Auto-confirm an unanswered Discord routing card#451

Merged
daniel-lxs merged 6 commits into
developfrom
fix/discord-routing-auto-confirm
Jul 17, 2026
Merged

[Fix] Auto-confirm an unanswered Discord routing card#451
daniel-lxs merged 6 commits into
developfrom
fix/discord-routing-auto-confirm

Conversation

@daniel-lxs

Copy link
Copy Markdown
Member

Problem

A Discord routing card had no timeout. Ask someone where to run a task, get distracted, and 15 minutes later the pending route expired with its Redis TTL — no launch, no message, the request simply gone.

Slack auto-confirms its suggestion after 30s (runSlackAutoConfirm), Telegram after 5s (scheduleTelegramRoutingAutoConfirm). Only Discord dropped the request on the floor. This was the last behavioral divergence from Slack in the routing flow.

Reusing rather than re-deriving

The shared constant is renamed SLACK_AUTO_CONFIRM_TIMEOUT_MSROUTING_AUTO_CONFIRM_TIMEOUT_MS in @roomote/cloud-agents — which is already what block-kit.ts:2123 calls it in prose — and Discord imports the same 30s value. One constant, two providers, no third copy. (Telegram's local 5s is left alone; changing it is a product call, not this fix.)

The timer is in-process like both existing implementations, so a restart loses it and the route falls back to expiring exactly as it does today. Claiming the pending route is atomic (GETDEL), so a click and the timer cannot both launch.

A bug found on the way

The card announced "The best match is X" using options[0] — but on a fallback decision the options are merely alphabetical and the router had no opinion, so the card named a "best match" it had never chosen. Auto-confirming that would launch an alphabetical accident.

The suggested option is now located in the rendered list (never assumed to be first — a suggestion can vanish before the card is built) and stored as suggestedIndex. The card only claims a best match when one exists, and a fallback card auto-confirms nothing.

Replace-by-message, not replace-by-interaction

launchDiscordTask's replaceMessage described an interaction, but an auto-confirm has no interaction to answer through — nobody clicked. It now describes a message: a click is still answered via its token (that path is live-tested and untouched), while the timer edits the card by id.

Both go through replaceOrPostDiscordMessage, the Discord analog of Slack's postOrReplaceSlackMessage, which falls back to posting when the card is gone — so a started task never loses its cancel control.

Verification

245 tests pass across the Slack, Discord and Telegram handlers. New coverage, driven with fake timers:

  • an unanswered card auto-confirms the suggestion and replaces the card by id
  • a fallback card announces no match and auto-confirms nothing, even well past the timeout
  • a route already claimed by a click is not launched twice

🤖 Generated with Claude Code

A Discord routing card had no timeout. Ask someone where to run a task,
get distracted, and 15 minutes later the pending route expired with its
Redis TTL — no launch, no message, the request simply gone. Slack
auto-confirms its suggestion after 30s and Telegram after 5s; only
Discord dropped the request.

Reuse Slack's mechanism rather than inventing a third: the shared
constant is renamed SLACK_AUTO_CONFIRM_TIMEOUT_MS ->
ROUTING_AUTO_CONFIRM_TIMEOUT_MS, which is what `block-kit.ts` already
called it in prose, and Discord imports the same 30s. The timer is
in-process like Slack's and Telegram's, so a restart loses it and the
route falls back to expiring. Claiming the pending route is atomic, so a
click and the timer cannot both launch.

Only a suggestion is auto-confirmed. The card previously announced
"The best match is X" using the first option even on a fallback, where
options are merely alphabetical and the router had no opinion — so it
named a best match it had not chosen. The suggested option is now
located in the rendered list and stored, the card only claims a match
when one exists, and a fallback card auto-confirms nothing.

`launchDiscordTask`'s `replaceMessage` now describes a message rather
than an interaction, since an auto-confirm has no interaction to answer
through: a click is still answered via its token, while the timer edits
the card by id. Both go through `replaceOrPostDiscordMessage`, the
analog of Slack's `postOrReplaceSlackMessage`, which falls back to
posting when the card is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roomote-roomote

roomote-roomote Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

No new code issues found. See task

  • apps/api/src/handlers/discord/replies.ts:55-60 Edit thread messages through the thread channel ID. cardChannel.channelId is the thread ID, but this passes parentChannelId to editMessage, so Discord returns Unknown Message; the fallback posts a second acknowledgement and leaves the routing card and buttons stale.
  • apps/api/src/handlers/discord/routing-confirmation.ts:360-366 Do not resurrect a route claimed while the card post is completing. A fast Nevermind click can GETDEL the initial state before this unconditional second SET; this write recreates it, and the timer then auto-starts the canceled request. Use a conditional existing-key update, as the Telegram path does with XX.

Reviewed 88032d2

Two real bugs in the auto-confirm path.

Editing the card addressed the thread's parent channel. A thread is
itself a channel and `editMessage` takes no separate thread id, unlike
`postMessage` — so the edit could only ever return Unknown Message, and
the fallback would post a second acknowledgement beside a routing card
still showing live buttons. The exact duplicate this flow just removed.

The card id is only knowable after posting, so the route is written a
second time. That write was unconditional, so a Nevermind landing while
the card was still posting would have its claim overwritten, bringing
the route back from the dead for the timer to launch. Telegram already
solved this with `XX` and said so in a comment; Discord now matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@daniel-lxs

Copy link
Copy Markdown
Member Author

Both real, both fixed in ca0f62c. Thanks — the second one would have been ugly in production.

1. Editing through the parent channel. Correct, and the failure mode is exactly the duplicate this flow just removed: the edit could only ever return Unknown Message, so the fallback would post a second acknowledgement beside a routing card still showing live buttons. The cause was copying postMessage's convention (channelId: parent, threadId: thread) to editMessage, which takes no separate thread id — a thread is itself a channel and the message lives in it. Now addressed by channel.channelId, with the returned result still reporting channelId/threadId the way postMessage does.

2. Resurrecting a claimed route. Also correct. The card id is only knowable after posting, so the route is written a second time, and that write was unconditional — a Nevermind landing in that window would have its GETDEL claim overwritten, and the timer would then launch the request the user just canceled. Now XX, so the write only ever updates a route that still exists. Telegram had already solved this and left a comment saying why; I copied its timer but not its storage semantics.

Tests added for both: a card in a thread is edited through the thread id (and posts only when the card is genuinely gone), and the second write is asserted to be conditional while the first is not.

@daniel-lxs
daniel-lxs merged commit 3dad393 into develop Jul 17, 2026
17 checks passed
@daniel-lxs
daniel-lxs deleted the fix/discord-routing-auto-confirm branch July 17, 2026 15:39
@roomote-roomote roomote-roomote Bot mentioned this pull request Jul 17, 2026
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.

1 participant