You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 🎉
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 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.
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.tsnever 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.
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.tsnever 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/ackarriving 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:187area)The steering branch of
/watchoverwrote DO storage'sreactionEventIdwith the new prompt's 👀 without redacting the prior. Now: before overwriting, fire-and-forget redact the priorreactionEventIdif it differs from the incoming one. A failed redact logs but doesn't break steering.Break 3 — Late
/ackafter terminalDispatch fires 👀 in parallel with watcher spawn, posting the id to the DO via
/ackasynchronously. If terminal lands first, the swap runs with no prior to redact (placing just 🎉), then the late/ackarrives and the old handler dumbly stored its 👀 id — orphan stacking against the 🎉.Now: on
/ackentry, checkphase. If DONE/FAILED, redact the incomingackReactionIdimmediately 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 typecheckcleanbun test src/render.test.ts— 43/43 pass176abf80)/ackafter a fast terminal (e.g., text-only turn under rate limit) and verify no stale 👀 stacks against the 🎉Workspace · Slack Thread