Skip to content

perf(telegram-bridge): rate-limit-aware edit cadence + 429 self-tune#12

Open
replicas-connector[bot] wants to merge 1 commit into
feat/replicas-telegram-bridgefrom
perf/tg-bridge-rate-limit-aware-cadence
Open

perf(telegram-bridge): rate-limit-aware edit cadence + 429 self-tune#12
replicas-connector[bot] wants to merge 1 commit into
feat/replicas-telegram-bridgefrom
perf/tg-bridge-rate-limit-aware-cadence

Conversation

@replicas-connector

@replicas-connector replicas-connector Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Three borrowed patterns from the ACP / Telegram bot status research doc. Targets the Telegram-side equivalent of the matrix.org M_LIMIT_EXCEEDED regression we fixed earlier — group chats hit Telegram's 20/min editMessageText cap on long turns and fragment the status frame into multiple bubbles.

What it does

1. Group vs private edit floor

EDIT_MIN_INTERVAL_MS_PRIVATE = 800   // Telegram ~1/sec per chat
EDIT_MIN_INTERVAL_MS_GROUP   = 3500  // Telegram 20/min → 3s, padded

Picked via isGroupChat(chatId) — Telegram convention: chat_id < 0 means group / supergroup / channel. Previously a single EDIT_MIN_INTERVAL_MS = 500 for everything.

2. Char-delta edit gate

n3d1117/chatgpt-telegram-bot pattern. On non-terminal renders with no phase transition, skip the edit if the rendered text length changed by fewer than CHAR_DELTA_CUTOFF = 50 chars. Stops thinking-preview ticks from burning quota on 5-char diffs. Phase transitions (e.g. STARTING → RUNNING) always edit through so the user sees the header emoji + label swap fast.

3. Self-tuning per-turn 429 backoff

Each 429 response from Telegram bumps backoffBonusMs by BACKOFF_PER_429_MS = 1500, capped at MAX_BACKOFF_BONUS_MS = 10_000. The bonus is added to the effective edit floor, so noisy turns naturally settle into a sustainable cadence. Wiped on /watch fresh-spawn so calm turns start fast.

Bonus: kill the fragmentation regression

Previously when editStatus failed it fell through to sendStatus, creating a new bubble per failed edit — same bug Matrix had pre-d79c798. Now editStatus returns a discriminated { ok, rateLimited?, retryAfterMs? } and a 429 result skips the sendStatus fallback, preserving the existing statusMessageId for the next allowed tick.

Telegram's 429 carries retry_after in seconds inside parameters.* — parsed and converted to ms; honored via rateLimitedUntil in DO storage the same way Matrix's bridge handles it.

Cadence tweak

Bumped TICKER_REFRESH_MS 1500 → 2500 in line with the OpenACP observation (research doc, line 27 of THINKING_REFRESH_MS = 15s) that the field-conservative norm is much slower than us. We're still more aggressive but no longer redlining the cap.

Test plan

  • bun run typecheck clean
  • bun test src/render.test.ts — 44/44 pass
  • bun test overall — 65/73 pass; the 8 failures under bun test are pre-existing vi.stubGlobal-under-bun mismatches per docs/agent-handoff.md, NOT introduced by this change
  • Deployed to TG bridge worker f94db34a
  • Live test: send a multi-step prompt in a TG group; expect one status frame that edits in place all the way to Done, never fragments
  • Live test: send a flurry of prompts in a TG group; expect the backoff bonus to ratchet up and cadence to slow naturally

What's NOT in this PR (deferred)

  • Telegram render.ts focus-window revert (still has commit 8728c39 code, per handoff). Out of scope here — touch poller only.
  • 3-minute thinking auto-stop (the OpenACP THINKING_MAX_MS pattern). Would need render-side changes; held for a separate PR.
  • Lossless tool history (OpenACP's many-card model). That's a rewrite, not a tune.

Created by Jaden (jadengarza@pm.me) with Replicas
Workspace  ·  Slack Thread

Open in Devin Review

Three-borrow PR from the ACP / Telegram bot status research in
docs/acp-telegram-status-research.md. Targets the per-room
M_LIMIT_EXCEEDED equivalent on the Telegram side — group rooms hit
the 20/min editMessageText cap on long turns, fragmenting the status
frame into multiple bubbles.

(1) Group vs private edit floor
    EDIT_MIN_INTERVAL_MS_PRIVATE = 800   (Telegram ~1/sec per chat)
    EDIT_MIN_INTERVAL_MS_GROUP   = 3500  (Telegram 20/min → 3s, padded)
    Picked via `isGroupChat(chatId)` — Telegram convention: chat_id < 0
    means group / supergroup / channel.

(2) Char-delta edit gate (n3d1117/chatgpt-telegram-bot pattern)
    On non-terminal renders with no phase transition, skip the edit if
    the rendered text length changed by fewer than CHAR_DELTA_CUTOFF
    (50) chars. Stops thinking-preview ticks from burning quota on
    5-char diffs. Phase transitions (e.g. STARTING → RUNNING) always
    edit through so the user sees the header emoji + label swap fast.

(3) Self-tuning per-turn 429 backoff
    Each 429 response from Telegram bumps `backoffBonusMs` by
    BACKOFF_PER_429_MS = 1500ms, capped at MAX_BACKOFF_BONUS_MS =
    10_000. The bonus is added to the effective edit floor, so noisy
    turns naturally settle into a sustainable cadence. Wiped on
    /watch fresh-spawn so calm turns start fast.

Also stops the fragmentation regression on the TG side: previously
when editStatus failed it fell through to sendStatus, creating a new
bubble per failed edit (same bug Matrix had pre-d79c798). Now
editStatus returns a discriminated `{ ok, rateLimited?, retryAfterMs? }`
and a 429 result skips the sendStatus fallback, preserving the
existing statusMessageId for the next allowed tick.

Telegram's 429 carries retry_after in seconds inside parameters.* —
parsed and converted to ms; honored via rateLimitedUntil in DO storage
the same way Matrix's bridge handles it.

Bumped TICKER_REFRESH_MS 1500 → 2500 in line with the OpenACP
observation that 5s text-stream debounce + 15s thinking ticker is the
field-conservative norm. We're still much more aggressive than them
but no longer redlining the cap.

Tests: 65/65 pass on the paths this PR touches. The 8 failures under
`bun test` are pre-existing vi.stubGlobal-under-bun mismatches per
docs/agent-handoff.md — not introduced by this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: itsablabla <itsablabla@users.noreply.github.com>
@capy-ai

capy-ai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 New per-turn state keys not cleaned up on fresh-spawn, causing stale backoff to leak into subsequent turns

The handleWatch fresh-spawn path at replicas-telegram-bridge/src/poller.ts:200-208 selectively deletes specific storage keys before seeding a new turn, but does NOT include the four new keys introduced by this PR: backoffBonusMs, rateLimitedUntil, lastRenderedLen, and lastEditedPhase. The comment on BACKOFF_PER_429_MS (line 83) explicitly states these "Wipe when the DO state resets on the next /watch fresh-spawn, so it's per-turn — noisy turns settle into a sustainable cadence, calm turns start fast." However this invariant is violated. When a follow-up message arrives within the 30s pendingCleanup window (before deleteAll() fires), the steering gate rejects it (pendingCleanup=true) and falls through to fresh-spawn — inheriting up to 10,000ms of backoffBonusMs from the previous rate-limited turn. This causes the new turn's status edits to be throttled far more aggressively than intended, degrading the live-edit streaming experience.

(Refers to lines 200-208)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🔴 New per-turn state keys not cleaned up on fresh-spawn, causing stale backoff to leak into subsequent turns

The handleWatch fresh-spawn path at replicas-telegram-bridge/src/poller.ts:200-208 selectively deletes specific storage keys before seeding a new turn, but does NOT include the four new keys introduced by this PR: backoffBonusMs, rateLimitedUntil, lastRenderedLen, and lastEditedPhase. The comment on BACKOFF_PER_429_MS (line 83) explicitly states these "Wipe when the DO state resets on the next /watch fresh-spawn, so it's per-turn — noisy turns settle into a sustainable cadence, calm turns start fast." However this invariant is violated. When a follow-up message arrives within the 30s pendingCleanup window (before deleteAll() fires), the steering gate rejects it (pendingCleanup=true) and falls through to fresh-spawn — inheriting up to 10,000ms of backoffBonusMs from the previous rate-limited turn. This causes the new turn's status edits to be throttled far more aggressively than intended, degrading the live-edit streaming experience.

(Refers to lines 200-208)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration[bot] This repository is not connected to a Replicas organization.

To enable Replicas on this repository:

  1. Go to GitHub integration settings
  2. Add this repository to your organization

Note: Each repository can only be connected to one Replicas organization.

Comment on lines +630 to +633
const lastRenderedLen = (await this.state.storage.get<number>("lastRenderedLen")) ?? 0;
const lastPhase = await this.state.storage.get<Phase>("lastEditedPhase");
const phaseChanged = lastPhase !== undefined && lastPhase !== s.phase;
if (!phaseChanged && Math.abs(text.length - lastRenderedLen) < CHAR_DELTA_CUTOFF) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Char-delta gate blocks elapsed-time ticker updates, freezing the timer display

The char-delta gate at line 633 skips edits when Math.abs(text.length - lastRenderedLen) < CHAR_DELTA_CUTOFF (50 chars) and there's no phase transition. Elapsed-time ticker updates (e.g., "🤔 Starting · 3s" → "🤔 Starting · 5s") change the text by 0–2 characters, so they are ALWAYS blocked by this gate within the same phase. This directly defeats the stated purpose of TICKER_REFRESH_MS (line 71-73: "refresh the rendered status... so the ⏱ ticker stays live"). In practice, the user sees a frozen timer during the STARTING/PLANNING phases until the first tool call arrives and adds 50+ chars of content. The alarmInner code at line 542-544 still triggers renderAndSend() when tickerStale is true, but the edit never reaches Telegram.

Prompt for agents
The char-delta gate at line 633 in renderAndSend() blocks ticker-only updates because elapsed time changes (e.g. '3s' to '5s') alter the text by only 0-2 characters, well under CHAR_DELTA_CUTOFF=50. This conflicts with the TICKER_REFRESH_MS mechanism (line 71-73, 542-544) which explicitly exists to keep the elapsed timer live.

Possible approaches:
1. Add a 'tickerStale' boolean parameter to renderAndSend() and bypass gate (3) when the caller is the ticker-stale path. This preserves the char-delta gate's intent (blocking trivial thinking-preview updates) while allowing ticker refreshes.
2. Track last-successful-edit time separately from last-attempted-edit time, and only apply the char-delta gate when the last successful edit was recent (< TICKER_REFRESH_MS ago). If it's been > TICKER_REFRESH_MS since the last successful edit, bypass gate (3).
3. Compare the actual text content (not just length) to determine if the change is ticker-only vs thinking-preview. But this is complex and fragile.

Approach (1) seems cleanest — add a parameter like `bypassCharDelta?: boolean` defaulting to false, and pass true from the tickerStale callsite in alarmInner.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 Char-delta gate blocks elapsed-time ticker updates, freezing the timer display

The char-delta gate at line 633 skips edits when Math.abs(text.length - lastRenderedLen) < CHAR_DELTA_CUTOFF (50 chars) and there's no phase transition. Elapsed-time ticker updates (e.g., "🤔 Starting · 3s" → "🤔 Starting · 5s") change the text by 0–2 characters, so they are ALWAYS blocked by this gate within the same phase. This directly defeats the stated purpose of TICKER_REFRESH_MS (line 71-73: "refresh the rendered status... so the ⏱ ticker stays live"). In practice, the user sees a frozen timer during the STARTING/PLANNING phases until the first tool call arrives and adds 50+ chars of content. The alarmInner code at line 542-544 still triggers renderAndSend() when tickerStale is true, but the edit never reaches Telegram.

Prompt for agents
The char-delta gate at line 633 in renderAndSend() blocks ticker-only updates because elapsed time changes (e.g. '3s' to '5s') alter the text by only 0-2 characters, well under CHAR_DELTA_CUTOFF=50. This conflicts with the TICKER_REFRESH_MS mechanism (line 71-73, 542-544) which explicitly exists to keep the elapsed timer live.

Possible approaches:
1. Add a 'tickerStale' boolean parameter to renderAndSend() and bypass gate (3) when the caller is the ticker-stale path. This preserves the char-delta gate's intent (blocking trivial thinking-preview updates) while allowing ticker refreshes.
2. Track last-successful-edit time separately from last-attempted-edit time, and only apply the char-delta gate when the last successful edit was recent (< TICKER_REFRESH_MS ago). If it's been > TICKER_REFRESH_MS since the last successful edit, bypass gate (3).
3. Compare the actual text content (not just length) to determine if the change is ticker-only vs thinking-preview. But this is complex and fragile.

Approach (1) seems cleanest — add a parameter like `bypassCharDelta?: boolean` defaulting to false, and pass true from the tickerStale callsite in alarmInner.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration[bot] This repository is not connected to a Replicas organization.

To enable Replicas on this repository:

  1. Go to GitHub integration settings
  2. Add this repository to your organization

Note: Each repository can only be connected to one Replicas organization.

@replicas-connector

Copy link
Copy Markdown
Contributor Author

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

@devin-ai-integration[bot] This repository is not connected to a Replicas organization.

To enable Replicas on this repository:

  1. Go to GitHub integration settings
  2. Add this repository to your organization

Note: Each repository can only be connected to one Replicas organization.

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.

0 participants