Concurrent deliveries to one Telegram session crash the run with HookConflictError → session.failed
Environment
eve@0.12.0
- Deployed on Vercel (Vercel Workflow world)
- Built-in
telegramChannel() from eve/channels/telegram
- Telegram forum topics in a 1:1 chat (each topic = its own session via a thread-aware
principalId)
Summary
When a second Telegram update for the same session (same chatId:threadId) arrives while the previous turn's workflow run is still active (not parked at a drain boundary), the run dies with:
HookConflictError: Hook token "telegram:<chatId>:<threadId>:" is already in use
by another workflow (run "wrun_…")
Because nothing catches it, the default session.failed handler then posts the user-facing "This session could not recover from an error… Start a new message to continue." A single too-fast follow-up therefore kills the conversation.
This is most visible on long turns (multi-minute tool work), where the window for a colliding delivery is widest.
Repro
- Use the built-in
telegramChannel() (no custom dispatch).
- Send a message that triggers a long turn (e.g. several slow tool calls) into a topic.
- While it's still working, send a second message into the same topic.
- The second delivery throws
HookConflictError; the run transitions to session.failed and posts the "could not recover" message.
Expected
A concurrent/duplicate delivery to a busy session should be coalesced, queued, or dropped — not escalated to a session-fatal error. At minimum, HookConflictError on inbound delivery should not surface to the end user as "session could not recover."
Actual
The conflict propagates and the session is failed.
Notes on root cause (from reading the compiled source)
dispatchMessage (telegramChannel.js) delivers every inbound update via send(..., { continuationToken: continuationTokenFromState(state), state }), where the continuation token is telegram:<chatId>:<threadId>:<conversationId> — i.e. the same string as the workflow hook token.
- For sequential messages this correctly resumes the existing run, so normal back-and-forth works. The failure is purely a concurrency race: a delivery that lands while the run holds the hook but isn't at a drain boundary.
- The execution-model docs already acknowledge this: "When a turn is already active, the hook may accept additional deliveries, but the runtime only drains them at specific workflow boundaries… that drain is best-effort." — but when the drain doesn't happen, the result is a hard
HookConflictError rather than a graceful no-op.
defaultEvents has no handler that distinguishes this case, so it falls through to session.failed.
Suggested directions (any one would help)
- Swallow inbound hook conflicts in the channel dispatch. When
send() rejects with HookConflictError, drop or buffer the delivery instead of failing the session (the active run will still process its current turn).
- Built-in per-session coalescing. Optionally fold a conflicting delivery into the next turn at the next drain boundary, matching the "best-effort drain" already described.
- Don't render
HookConflictError as session.failed. Even if it's left to the app to queue, an inbound-delivery conflict shouldn't read to the user as an unrecoverable session error.
Workaround (for others hitting this)
Maintain an app/channel-layer per-session queue keyed by chatId:threadId: mark the session busy on first delivery, buffer subsequent deliveries, and re-deliver after session.waiting. Requires an external store any webhook invocation can reach (defineState is session-scoped and can't coordinate the conflicting invocation). For a low-volume single-user bot, simply not sending a second message before the first turn finishes avoids it.
Concurrent deliveries to one Telegram session crash the run with
HookConflictError→session.failedEnvironment
eve@0.12.0telegramChannel()fromeve/channels/telegramprincipalId)Summary
When a second Telegram update for the same session (same
chatId:threadId) arrives while the previous turn's workflow run is still active (not parked at a drain boundary), the run dies with:Because nothing catches it, the default
session.failedhandler then posts the user-facing "This session could not recover from an error… Start a new message to continue." A single too-fast follow-up therefore kills the conversation.This is most visible on long turns (multi-minute tool work), where the window for a colliding delivery is widest.
Repro
telegramChannel()(no custom dispatch).HookConflictError; the run transitions tosession.failedand posts the "could not recover" message.Expected
A concurrent/duplicate delivery to a busy session should be coalesced, queued, or dropped — not escalated to a session-fatal error. At minimum,
HookConflictErroron inbound delivery should not surface to the end user as "session could not recover."Actual
The conflict propagates and the session is failed.
Notes on root cause (from reading the compiled source)
dispatchMessage(telegramChannel.js) delivers every inbound update viasend(..., { continuationToken: continuationTokenFromState(state), state }), where the continuation token istelegram:<chatId>:<threadId>:<conversationId>— i.e. the same string as the workflow hook token.HookConflictErrorrather than a graceful no-op.defaultEventshas no handler that distinguishes this case, so it falls through tosession.failed.Suggested directions (any one would help)
send()rejects withHookConflictError, drop or buffer the delivery instead of failing the session (the active run will still process its current turn).HookConflictErrorassession.failed. Even if it's left to the app to queue, an inbound-delivery conflict shouldn't read to the user as an unrecoverable session error.Workaround (for others hitting this)
Maintain an app/channel-layer per-session queue keyed by
chatId:threadId: mark the session busy on first delivery, buffer subsequent deliveries, and re-deliver aftersession.waiting. Requires an external store any webhook invocation can reach (defineStateis session-scoped and can't coordinate the conflicting invocation). For a low-volume single-user bot, simply not sending a second message before the first turn finishes avoids it.