Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions replicas-matrix-bridge/src/poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,24 @@ export class ReplicaPoller {
// send fresh.
const body = (await req.json()) as { ackReactionId?: string; initialStatusEventId?: string };
const writes: Record<string, unknown> = {};
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>("phase");
const watch = await this.state.storage.get<WatchSpec>("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;
}
Comment on lines +126 to +128

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.

}

if (body.initialStatusEventId) {
const existing = await this.state.storage.get<string>("statusEventId");
// Only adopt if we haven't already started editing a frame.
Expand Down Expand Up @@ -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<string>("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();
Expand Down