feat(telegram): typing-indicator heartbeat while a turn is in flight#294
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 69d40929f0
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| bot.api.config.use( | ||
| autoRetry({ |
There was a problem hiding this comment.
Exclude typing heartbeats from auto-retry
When Telegram rate-limits sendChatAction, this global transformer also applies to the new replyWithChatAction("typing") heartbeat. Those pulse promises are intentionally fire-and-forget, and stopHeartbeat() only clears future intervals, so a 429 with retry_after can leave already-scheduled chat-action retries sleeping for up to 300 seconds and then firing after the turn has completed, showing stale typing indicators and potentially creating a burst of extra API calls. Consider bypassing auto-retry for chat actions or making heartbeat pulses abortable/non-retried.
Useful? React with 👍 / 👎.
| Timeout: "provider-down", | ||
| Network: "provider-down", |
There was a problem hiding this comment.
Route intervals network failures to intervals copy
For an intervals-icu-api Timeout or Network error, isIntervalsApiError takes this routing before the generic classifier, and the later provider-down branch tells the athlete that the model provider is having trouble. In the scenario where intervals.icu is unreachable but the model is fine, that is the wrong user-facing diagnosis; these kinds should use the intervals-specific copy or a separate “couldn't reach intervals.icu” message.
Useful? React with 👍 / 👎.
69d4092 to
ca10d51
Compare
|
Rebased onto the updated delivery branch and addressed the two findings that live in the heartbeat code (commit ca10d51):
Base retargeted to |
A chat turn can run up to ~10 min, during which the athlete previously got zero feedback and would assume the bot was dead. Pulse Telegram's native "typing" chat action every 4s for the generation phase of the shared turn skeleton, stopping it in a finally around generation (never delivery). Each pulse is best-effort — a rejected or throwing pulse is logged at debug and never affects the turn or its reply — and the interval is unref'd so a pending pulse can't hold the process open during shutdown or an /update drain. Closes #167
- add an in-flight guard so a beat is skipped while the previous pulse is still unsettled, preventing pulse pile-up when a pulse is parked in the API retry layer; the flag is released in a finally so a rejected pulse can't wedge it - export TYPING_HEARTBEAT_MS and assert the <=5s auto-clear invariant against the production constant instead of a test-local mirror
ca10d51 to
432ed5a
Compare
Summary
A chat turn can now legitimately run up to ~10 minutes. During that window the athlete previously got zero feedback — the Telegram channel dispatches the turn fire-and-forget and sends nothing until the final reply or the error copy. Silence for minutes reads as "the bot is dead," so athletes re-send, and the re-send queues behind the in-flight turn on the per-chat session lock, compounding the perceived hang.
This adds Telegram's native typing indicator as a heartbeat while a turn is in flight.
Closes #167.
Changes
startTypingHeartbeat(pulse, intervalMs, onError)(exported, unit-tested): fires one pulse immediately, then everyTYPING_HEARTBEAT_MS(4s — under Telegram's ~5s auto-clear so it never flickers). Each pulse is best-effort — a rejected/throwing pulse is routed toonError(logged at debug) and can never affect the turn. The interval isunref'd so a pending pulse can't hold the process open during shutdown / an/updatedrain.runTurn: the heartbeat starts immediately beforeagent.chat(...)is awaited and stops in afinallyaround the generation try/catch only (never delivery) — so it clears on success, on a classified-error reply, and on a throw.opts.ctxwidened to includereplyWithChatAction. The existing generation/delivery/resend logic and all copy are byte-identical.runTurn(theagent.chatgeneration phase, where the 10-minute silence lives); non-generation command handlers are untouched.Acceptance criteria
agent.chatis in flight (refreshed ≤ every 5s).sendChatActionfailure never surfaces to the athlete and never alters the reply or error path.unref'd.All four are covered by new fake-timer integration tests +
startTypingHeartbeatunit tests intelegram-dispatch.test.ts.Testing
pnpm check→ exit 0 (build, typecheck incl. tests, lint, trademarks, fixture-privacy, registry-isolation, changeset gates).telegram-dispatch.test.ts53 passed (47 pre-existing + 6 new).Note on the base
This work depends on the split generation/delivery
runTurnintroduced in #285. It is branched off that PR, so until #285 merges, this PR's diff also carries #285's commits. Once #285 lands onmainI'll rebase this branch ontomainfor a clean single-commit diff.