Skip to content

perf(matrix-bridge): char-delta edit gate + self-tuning 429 backoff#13

Open
replicas-connector[bot] wants to merge 2 commits into
feat/replicas-telegram-bridgefrom
perf/matrix-bridge-char-delta-and-self-tune-backoff
Open

perf(matrix-bridge): char-delta edit gate + self-tuning 429 backoff#13
replicas-connector[bot] wants to merge 2 commits into
feat/replicas-telegram-bridgefrom
perf/matrix-bridge-char-delta-and-self-tune-backoff

Conversation

@replicas-connector

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

Copy link
Copy Markdown
Contributor

Summary

Matrix-side companion to PR #12 — the two patterns from the research doc that generalize across both bridges. Closes the gap Jaden flagged: I shipped these for TG but not for Matrix.

What Matrix already had

  • retry_after_ms honoring via rateLimitedUntil
  • Terminal-render 4× inline retry ✓
  • 2000ms edit floor ✓
  • No sendMessage fallback on 429 (preserves statusEventId) ✓

What this PR adds

1. Char-delta edit gate

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 (5 chars of italic narration delta) from burning matrix.org quota.

Phase transitions (STARTING → RUNNING, PLANNING → EDITING, etc.) always edit through so the user sees the header emoji + label swap immediately.

2. Self-tuning per-turn 429 backoff bonus

Each non-terminal 429 from matrix.org bumps backoffBonusMs by BACKOFF_PER_429_MS = 1500ms, capped at MAX_BACKOFF_BONUS_MS = 10_000ms. The bonus adds to the EDIT_MIN_INTERVAL_MS floor so subsequent edits in the same turn space out and the chain settles instead of bouncing off the limit repeatedly.

Previously each 429 only deferred to now + retry_after_ms without ratcheting — the next allowed tick could immediately hit another 429.

Fresh-spawn wipe

Both knobs (backoffBonusMs, rateLimitedUntil, lastRenderedLen, lastEditedPhase) added to the existing storage.delete batch in the /watch fresh-spawn path. Calm turns start fast, noisy turns self-throttle within their own scope.

Why no group/DM cadence split (the third TG borrow)

Doesn't port — matrix.org's per-room rate cap is uniform regardless of room size, so the TG group/DM signal isn't useful here. The 2000ms floor already targets matrix.org's quota directly.

Test plan

  • bun run typecheck clean
  • bun test src/render.test.ts — 43/43 pass
  • Deployed to matrix bridge worker 4be67d8a
  • Live test: send a multi-step prompt in a busy room; expect the bridge to ratchet down its edit rate as 429s come in instead of bouncing off the limit
  • Live test: send a trivial prompt right after; expect it to start fast (backoffBonusMs wiped on fresh /watch)

Stacks on

This PR is independent of PR #11 (one-emoji invariant) — both touch different parts of poller.ts. Merge in either order.


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

Open in Devin Review

Matrix port of the two patterns from PR #12 that generalize cleanly
across both bridges. Matrix already has retry_after_ms honoring via
rateLimitedUntil and terminal-render 4× retry; what was missing:

(1) Char-delta edit gate
    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 (5 chars of italic
    narration delta) from burning matrix.org quota. Phase transitions
    (STARTING → RUNNING, etc.) always edit through so the header
    emoji + label swap stays snappy.

(2) Self-tuning per-turn 429 backoff bonus
    Each non-terminal 429 from matrix.org bumps backoffBonusMs by
    BACKOFF_PER_429_MS (1500) ms, capped at MAX_BACKOFF_BONUS_MS
    (10s). The bonus adds to the EDIT_MIN_INTERVAL_MS floor so
    subsequent edits in the same turn space out and the chain settles
    instead of bouncing off the limit repeatedly. Previously each 429
    only deferred to `now + retry_after_ms` without ratcheting, so
    the next allowed tick could immediately hit another 429.

Both knobs wipe on /watch fresh-spawn (added backoffBonusMs +
rateLimitedUntil + lastRenderedLen + lastEditedPhase to the existing
storage.delete batch) — calm turns start fast, noisy turns
self-throttle within their own scope.

Group-vs-DM cadence (the third borrow in PR #12) does NOT port to
Matrix — matrix.org's per-room rate cap is uniform regardless of room
size, so the TG group/DM split isn't the right signal here.

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.

Second follow-up after Jaden asked to go deeper. Read all relevant TG
plugin files in Open-ACP/OpenACP end-to-end:
  - activity.ts (491 lines): ThinkingIndicator + ToolCard + ActivityTracker
  - streaming.ts (290 lines): MessageDraft text streaming
  - formatting.ts (451 lines): markdownToTelegramHtml + renderToolCard
  - topic-manager.ts (159 lines): per-session forum topics
  - permissions.ts (126 lines): inline keyboard permission flow

Documents the actual mechanics the prior summary missed:

- ThinkingIndicator state machine: idle → showing (15s refresh) →
  dismissed (msg stays in chat); auto-stop at 3 min enforced by the
  refresh body itself; dismissed re-checked AFTER sendQueue wait
  because the agent may have moved on during the queue wait.

- ToolCard internals: flushPromise as serializing chain; lastSentText
  dedup; overflow strip (strip outputContent from specs when render
  exceeds 4096); overflow chunks recycled via editMessageText; stale
  chunks deleted when count decreases; aborted flag for destroy().

- ActivityTracker seal-and-keep vs clear-on-new-prompt: out-of-order
  tool updates after a seal still route to previousToolStateMap; new
  prompt clears previous refs to prevent cross-turn leakage.

- MessageDraft critical patterns: snapshot buffer BEFORE await (with
  the explicit "append() can mutate" comment); do NOT reset messageId
  on transient errors (matches our hard-won d79c798 lesson);
  displayTruncated flag for finalize correctness; "enqueue all chunks
  in tight synchronous loop" to prevent concurrent handlers slipping
  between.

- renderToolCard visual order: completed-first, then plan, then
  running; ACP status icons ⬜🔄✅; tool-kind icon per-spec.

- Permission flow: nanoid(8) callback key for 64-byte callback_data
  limit; in-memory pending Map (not persistent across restarts);
  two-channel surface (session topic + Notifications topic with deep
  link); editMessageReplyMarkup({reply_markup: undefined}) strips
  buttons on response.

Closes with a prioritized table of borrowable patterns + effort
estimates. Top two cheap wins: 3-min thinking auto-stop (~10 lines
render-side) and displayTruncated flag (~5 lines poller-side).

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

@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 1 potential issue.

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.

🔴 Steering path doesn't reset lastRenderedLen/lastEditedPhase, causing char-delta gate to suppress the "💬 You:" injection

The steering path at replicas-matrix-bridge/src/poller.ts:197 resets lastRendered: "" to force a re-render, but does not reset the newly-added lastRenderedLen and lastEditedPhase storage keys. This creates an inconsistency: the text-equality guard (text === lastRendered) passes because lastRendered is "", but the char-delta gate at line 630 compares text.length against the stale lastRenderedLen from the previous edit cycle.

Concrete scenario: Turn is active with lastRenderedLen ≈ 350, phase is RUNNING. User sends a follow-up → steering injects 💬 <i>You: hello</i> (~30 extra chars) and resets lastRendered: "" but leaves lastRenderedLen = 350. The immediate renderAndSend() is throttled by gate (1) (edit interval). When the next attempt passes gate (1) 2-4s later, gate (3) evaluates Math.abs(~383 - 350) = 33 < 50suppressed. The user's follow-up line remains invisible in the status frame until the agent produces enough new content to push the delta past 50 characters.

(Refers to line 197)

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.

🔴 Steering path doesn't reset lastRenderedLen/lastEditedPhase, causing char-delta gate to suppress the "💬 You:" injection

The steering path at replicas-matrix-bridge/src/poller.ts:197 resets lastRendered: "" to force a re-render, but does not reset the newly-added lastRenderedLen and lastEditedPhase storage keys. This creates an inconsistency: the text-equality guard (text === lastRendered) passes because lastRendered is "", but the char-delta gate at line 630 compares text.length against the stale lastRenderedLen from the previous edit cycle.

Concrete scenario: Turn is active with lastRenderedLen ≈ 350, phase is RUNNING. User sends a follow-up → steering injects 💬 <i>You: hello</i> (~30 extra chars) and resets lastRendered: "" but leaves lastRenderedLen = 350. The immediate renderAndSend() is throttled by gate (1) (edit interval). When the next attempt passes gate (1) 2-4s later, gate (3) evaluates Math.abs(~383 - 350) = 33 < 50suppressed. The user's follow-up line remains invisible in the status frame until the agent produces enough new content to push the delta past 50 characters.

(Refers to line 197)

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 1 potential issue.

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