Skip to content

fix(matrix-bridge): enforce strict one-emoji invariant#11

Open
replicas-connector[bot] wants to merge 1 commit into
feat/replicas-telegram-bridgefrom
fix/strict-one-emoji-invariant
Open

fix(matrix-bridge): enforce strict one-emoji invariant#11
replicas-connector[bot] wants to merge 1 commit into
feat/replicas-telegram-bridgefrom
fix/strict-one-emoji-invariant

Conversation

@replicas-connector

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

Copy link
Copy Markdown
Contributor

Summary

Per Jaden's "Just one" directive on the Slack thread — closes two cases where the per-prompt redact-before-react flow was leaking orphaned 👀s across a steered conversation.

Before: Each prompt's reaction was correct on the happy path, but across a steered turn (Prompt A still in flight when Prompt B lands) Prompt A's 👀 was orphaned forever, and a late /ack arriving after terminal would stack a stale 👀 against the existing 🎉.

After: Strict one-emoji-at-a-time across the whole conversation. Each prompt converges to exactly one reaction.

Fixes

Break 2 — Steered follow-ups (poller.ts:187 area)

The steering branch of /watch overwrote DO storage's reactionEventId with the new prompt's 👀 without redacting the prior. Now: before overwriting, fire-and-forget redact the prior reactionEventId if it differs from the incoming one. A failed redact logs but doesn't break steering.

Break 3 — Late /ack after terminal

Dispatch fires 👀 in parallel with watcher spawn, posting the id to the DO via /ack asynchronously. If terminal lands first, the swap runs with no prior to redact (placing just 🎉), then the late /ack arrives and the old handler dumbly stored its 👀 id — orphan stacking against the 🎉.

Now: on /ack entry, check phase. If DONE/FAILED, redact the incoming ackReactionId immediately instead of storing it. The swap already happened; there's no future swap to clean it up.

Break 1 (out of scope, accepted)

The redact ultimately failing after 4 × 429 retries remains the residual edge case. Adding a sweep-on-cleanup retry loop would expand scope; opening a follow-up issue if it shows up in practice.

Test plan

  • bun run typecheck clean
  • bun test src/render.test.ts — 43/43 pass
  • Deployed to matrix bridge (version 176abf80)
  • Live test: send Prompt A → mid-turn send Prompt B → verify A's 👀 disappears when B's status frame transitions to active
  • Live test: trigger a slow /ack after a fast terminal (e.g., text-only turn under rate limit) and verify no stale 👀 stacks against the 🎉

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

Open in Devin Review

Per-prompt the swap (redact prior 👀, place 🎉/😭) was already correct.
Two cases were leaking orphaned 👀s and violating the "just one"
invariant across the whole conversation:

*Break 2 — steered follow-ups (poller.ts:187 area)*

When a user sends Prompt B while Prompt A's turn is still active, the
steering branch was overwriting DO storage's `reactionEventId` with
B's 👀 id without redacting A's 👀. Sequence:
  1. Prompt A → dispatch places 👀 on A; DO stores A's reaction id
  2. Prompt B mid-turn → dispatch places 👀 on B
  3. Steering overwrites DO's reactionEventId with B's id
  4. Terminal → swap redacts B's 👀 + places 🎉 on B
  5. A's 👀 stays forever — never redacted, never replaced

Fix: before overwriting in the steering branch, redact the prior
reactionEventId if it differs from the incoming one. Fire-and-forget
catch so a failed redact doesn't break steering itself.

*Break 3 — late `/ack` after terminal*

Dispatch fires the 👀 in parallel with the watcher spawn and posts the
id to the DO via `/ack` asynchronously. If terminal lands before
`/ack` arrives (slow tail latency, retried react, etc.), the swap runs
with no `reactionEventId` to redact and just places 🎉. When the late
`/ack` finally arrives, the old handler dumbly stored its 👀 id —
which is now an orphan stacking against the 🎉.

Fix: in the `/ack` handler, check `phase` on entry. If DONE/FAILED,
redact the incoming `ackReactionId` immediately instead of storing.
The swap already happened; there's no future swap to clean it up.

Break 1 (redact ultimately fails after 4 × 429 retries) remains the
narrow residual edge case — accepting that for now since the homeserver
rejecting a redact past retry budget is rare and adding a sweep-on-
cleanup retry loop would expand scope. Can be added if it shows up.

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

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +126 to +128
} else {
writes.reactionEventId = body.ackReactionId;
}

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 prior-👀 redact is dead code; the fix belongs in the /ack handler

The new redact logic at poller.ts:210-217 checks if (body.ackReactionId) inside the /watch steering path, but dispatch.ts never passes ackReactionId in the /watch call — both call sites (dispatch.ts:79 and dispatch.ts:105) pass undefined for that parameter. The ackReactionId always arrives later via the separate /ack endpoint (dispatch.ts:116-126). This means the steering-path redact code is unreachable, and the "orphaned 👀 on old prompt" bug the PR's comment describes is not actually fixed.

The /ack handler's non-terminal branch (poller.ts:127) simply overwrites reactionEventId without checking for or redacting the prior value, so during a steering follow-up the old prompt's 👀 is silently orphaned. The prior-reaction redact logic needs to live in the /ack handler's else branch, where watch is already available in scope.

Suggested change
} else {
writes.reactionEventId = body.ackReactionId;
}
} else {
const priorReactionId = await this.state.storage.get<string>("reactionEventId");
if (priorReactionId && priorReactionId !== body.ackReactionId && watch?.roomId) {
redact(matrixEnv(this.env), watch.roomId, priorReactionId, "ack: replaced prior 👀")
.catch((e) => console.log(`[poller] /ack redact prior 👀 failed: ${e instanceof Error ? e.message : e}`));
}
writes.reactionEventId = body.ackReactionId;
}
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 prior-👀 redact is dead code; the fix belongs in the /ack handler

The new redact logic at poller.ts:210-217 checks if (body.ackReactionId) inside the /watch steering path, but dispatch.ts never passes ackReactionId in the /watch call — both call sites (dispatch.ts:79 and dispatch.ts:105) pass undefined for that parameter. The ackReactionId always arrives later via the separate /ack endpoint (dispatch.ts:116-126). This means the steering-path redact code is unreachable, and the "orphaned 👀 on old prompt" bug the PR's comment describes is not actually fixed.

The /ack handler's non-terminal branch (poller.ts:127) simply overwrites reactionEventId without checking for or redacting the prior value, so during a steering follow-up the old prompt's 👀 is silently orphaned. The prior-reaction redact logic needs to live in the /ack handler's else branch, where watch is already available in scope.

Suggested change
} else {
writes.reactionEventId = body.ackReactionId;
}
} else {
const priorReactionId = await this.state.storage.get<string>("reactionEventId");
if (priorReactionId && priorReactionId !== body.ackReactionId && watch?.roomId) {
redact(matrixEnv(this.env), watch.roomId, priorReactionId, "ack: replaced prior 👀")
.catch((e) => console.log(`[poller] /ack redact prior 👀 failed: ${e instanceof Error ? e.message : e}`));
}
writes.reactionEventId = body.ackReactionId;
}
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 3 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