fix(channels): add idempotency guard to nack() callback (#104903)#104928
fix(channels): add idempotency guard to nack() callback (#104903)#104928evan-YM wants to merge 1 commit into
Conversation
) The shared nack() implementation in the channel message receive context lacked an already-nacked guard, so repeated sequential calls to nack() invoked onNack more than once. Add a guard that preserves the existing ack-to-nack transition and leaves rejected nack callbacks retryable. Add regression coverage: duplicate sequential nack idempotency, retry after a rejected onNack callback, and ack-to-nack transition. Add a standalone tsx verification script (scripts/verify-nack-idempotency.ts) that exercises the production receive module directly and reports 16/16 assertions passing as real-behavior proof. Co-Authored-By: Claude <[email protected]>
a4d0433 to
595c601
Compare
|
Codex review: needs maintainer review before merge. Reviewed July 11, 2026, 11:53 PM ET / July 12, 2026, 03:53 UTC. Summary PR surface: Source +5, Tests +54, Other +111. Total +170 across 3 files. Reproducibility: yes. at source level. Current main deterministically re-enters Review metrics: 1 noteworthy metric.
Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land one Do we have a high-confidence way to reproduce the issue? Yes at source level. Current main deterministically re-enters Is this the best way to solve the issue? Yes for the runtime approach: the shared receive context owns this invariant, and a AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 6b95f98fe70d. Label changesLabel justifications:
Evidence reviewedPR surface: Source +5, Tests +54, Other +111. Total +170 across 3 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
Closing as superseded by #104919, which is now the canonical fix for #104903. Thank you @evan-YM—the sequential idempotency and retry-after-failure cases here helped validate the lifecycle contract. The landed branch keeps those guarantees, also coalesces overlapping Canonical landing: #104919 |
Summary
Repeated sequential calls to a channel message receive context's
nack()invokeonNackmore than once. Add an already-nacked guard so completed nack side effects run once, while preserving the existing ack-to-nack transition and callback-retry contract.nack()unconditionally callsawait params.onNack?.(error)and only setsackState = "nacked"after the callback succeeds. A second sequential call repeats the completed callback.if (ctx.ackState === "nacked") return;before the callback — the narrowest guard that preserves ack-to-nack and retry-after-rejection.src/channels/message/receive.ts(+5 guard + comment),src/channels/message/lifecycle.test.ts(+54: duplicate-nack test, rejected-callback retry test),scripts/verify-nack-idempotency.ts(+109: standalone tsx verification script)Change Type & Scope
Change Type
Scope
Linked Issue
Motivation
The shared
nack()implementation in the channel message receive context is part of the public plugin SDK used by bundled channels (Telegram, LINE) and third-party plugins. Theack()method already has an idempotency guard (if (ctx.ackState === "acked") return;), butnack()was shipped without equivalent protection. Receive pipelines that revisit a completed failed stage can trigger duplicateonNackcallbacks, leading to repeated error handling, cleanup, or logging.Review Contract
Real Behavior Proof
Behavior addressed: Completed sequential
nack()calls invokedonNackrepeatedly; a second call after a successful first nack would re-fire the callback.Real environment tested: Linux x86_64, Node 24.13.1,
fix/nack-idempotency-guard-104903branch, production TS module loaded vianode --import tsx.Exact steps or command run after this patch:
Evidence after fix:
Matrix evidence:
Observed result after fix:
onNackexactly onceonNackcallback leavesackStateas "pending", allowing retryWhat was not tested:
Verification environment
Risks and Mitigations
bot-update-tracker.ts, LINEwebhook.ts, LINEwebhook-node.ts) each callnack()exactly once per receive context, so no existing behavior changes.await onNack. All 26 focused tests (18 lifecycle + 8 Telegram tracker) pass.MessageReceiveContexttype andcreateMessageReceiveContextfactory signature are unchanged. Third-party plugins using the SDK see the same contract with fixed idempotency.Security Impact
Compatibility / Migration
Human Verification
AI Assistance 🤖
src/channels/message/receive.ts. ClawSweeper issue review confirmed the already-nacked approach over a broader pending-only guard.