From 3a7361b58fc11461f95b8d3f8286e85e87118278 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Fri, 29 May 2026 16:30:40 +0000 Subject: [PATCH] fix(matrix-bridge): enforce strict one-emoji invariant across prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Co-Authored-By: itsablabla --- replicas-matrix-bridge/src/poller.ts | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/replicas-matrix-bridge/src/poller.ts b/replicas-matrix-bridge/src/poller.ts index 6b31c59e..05d8cb54 100644 --- a/replicas-matrix-bridge/src/poller.ts +++ b/replicas-matrix-bridge/src/poller.ts @@ -110,7 +110,24 @@ export class ReplicaPoller { // send fresh. const body = (await req.json()) as { ackReactionId?: string; initialStatusEventId?: string }; const writes: Record = {}; - if (body.ackReactionId) writes.reactionEventId = body.ackReactionId; + + // Strict "one emoji at a time" enforcement: if the watcher has + // already reached terminal (swap fired with no prior, leaving the + // 👀 unredacted), this incoming 👀 would stack against the 🎉/😭 + // that's already on the prompt. Immediately redact instead of + // storing — there's no future swap to clean it up. + if (body.ackReactionId) { + const phase = await this.state.storage.get("phase"); + const watch = await this.state.storage.get("watch"); + const isPostTerminal = phase === "DONE" || phase === "FAILED"; + if (isPostTerminal && watch?.roomId) { + redact(matrixEnv(this.env), watch.roomId, body.ackReactionId, "post-terminal stale ack") + .catch((e) => console.log(`[poller] /ack post-terminal redact failed: ${e instanceof Error ? e.message : e}`)); + } else { + writes.reactionEventId = body.ackReactionId; + } + } + if (body.initialStatusEventId) { const existing = await this.state.storage.get("statusEventId"); // Only adopt if we haven't already started editing a frame. @@ -184,7 +201,20 @@ export class ReplicaPoller { // Re-point reactionEventId at the NEW prompt's 👀 so terminal // places the final emoji on whatever message the user just sent // (and redacts that 👀, not the prior phase emoji). - if (body.ackReactionId) steerWrites.reactionEventId = body.ackReactionId; + // + // BEFORE overwriting, redact the prior reactionEventId (the prior + // prompt's 👀). Otherwise it stays orphaned forever — never + // redacted, never replaced — leaving a stale 👀 on the old prompt + // after the final 🎉 lands on the new one. Strict "one emoji at a + // time" invariant: each prompt converges to exactly one reaction. + if (body.ackReactionId) { + const priorReactionId = await this.state.storage.get("reactionEventId"); + if (priorReactionId && priorReactionId !== body.ackReactionId) { + redact(matrixEnv(this.env), body.roomId, priorReactionId, "steering: prior prompt finished") + .catch((e) => console.log(`[poller] steering redact prior 👀 failed: ${e instanceof Error ? e.message : e}`)); + } + steerWrites.reactionEventId = body.ackReactionId; + } await this.state.storage.put(steerWrites); console.log(`[poller] /watch steer replica=${body.replicaId} ev=${body.startEventId}`); await this.renderAndSend();