Skip to content

fix(adapters): drop duplicate webhook deliveries at ingest#1987

Open
truffle-dev wants to merge 2 commits into
coleam00:devfrom
truffle-dev:fix/webhook-delivery-idempotency
Open

fix(adapters): drop duplicate webhook deliveries at ingest#1987
truffle-dev wants to merge 2 commits into
coleam00:devfrom
truffle-dev:fix/webhook-delivery-idempotency

Conversation

@truffle-dev

@truffle-dev truffle-dev commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #1951@sbiitmc's diagnosis is exactly right: a duplicate delivery of the same comment passes every guard in handleWebhook, reaches ConversationLockManager.acquireLock, and gets queued behind the in-flight run as queued-conversation. The lock manager orders per-conversation messages but never dedups them, so when run #1 completes, .finally → processQueue replays the byte-identical message as a full second workflow run (full token cost, can push/open a second PR).

The incident's duplicate source — dual repo-webhook + App-webhook subscriptions — delivers the same comment under different delivery GUIDs, so deduping on X-GitHub-Delivery alone would miss it. The trigger path always carries a comment (close events return early in parseEvent), so the fix keys on the comment's identity instead: id + updated_at. Dual-subscription duplicates and LB double-forwards share that identity and get dropped; an edited comment gets a new updated_at and still re-triggers.

Changes

  • New DeliveryDeduplicator in packages/core/src/utils/delivery-dedup.ts (exported from core, reusable by the gitea/gitlab adapters later): a bounded first-seen cache — 10-minute TTL so deliberate manual redeliveries hours later still run, 10k-entry cap with oldest-first eviction so memory stays bounded.
  • GitHub adapter gates processing on comment:{owner}/{repo}#{number}:{comment.id}:{comment.updated_at} after the @mention check and before the expensive path (user resolution, conversation/codebase creation, clone/sync, comment-history fetch). Drops log as github.duplicate_delivery_dropped at info.
  • Falls back to delivery:{deliveryId} when the payload lacks comment identity, and fails open when neither is available — never drops a webhook for want of a key. The dedup check sits after signature verification so unauthenticated junk can't poison the cache.
  • X-GitHub-Delivery was already extracted in the webhook route but only used in error logs; it's now threaded to handleWebhook as an optional third parameter (per the issue's scope note). Gitea/gitlab adapters untouched.
  • WebhookEvent.comment type gains optional id/updated_at (both present on real GitHub deliveries).

Tests

  • delivery-dedup.test.ts (new, wired into core's test chain): first-seen/repeat, key independence, edit-forms-new-key, TTL expiry, prune-on-insert, max-size eviction, post-expiry refresh — 8 cases.
  • adapter.test.ts, new webhook delivery dedup block: same-GUID repeat dropped, dual-subscription duplicate (same comment, different GUIDs) dropped — the Duplicate webhook delivery runs a workflow twice (no ingest idempotency) #1951 incident shape, edited comment re-processed, distinct comments independent, GUID fallback, fail-open with no key — 6 cases asserting on conversation-creation call counts.
  • Pre-fix audit: no existing dedup/idempotency mechanism anywhere in core/adapters/server (grepped idempoten|dedup|deliveryId|x-github-delivery), and no test pinned the duplicate-processing behavior.

Validation

bun run lint --max-warnings 0, format:check pass repo-wide. Full adapters suite green (70-test github adapter lane + all chained lanes). Core chain green through the utils lanes including the new one (the long-tail orchestrator/credentials lanes are untouched by this diff and left to CI). tsc --noEmit clean on core/adapters/server apart from pre-existing packages/providers copilot-client drift unrelated to this change.

Closes #1951

Summary by CodeRabbit

  • New Features

    • GitHub webhook delivery deduplication to prevent duplicate processing, with fallback to delivery GUIDs for edge cases
    • Comment edit tracking so edits re-trigger processing instead of being treated as duplicates
  • Tests

    • New unit tests validating deduplication, TTL/expiry behavior, edit re-triggering, eviction, and various delivery scenarios
  • Chores

    • Exposed deduplication utility in public API and updated test runner command

…1951)

A duplicate delivery of the same comment passed every guard and queued a
byte-identical second workflow run behind the first: the lock manager
orders per-conversation messages but never dedups them. Dual repo+App
webhook subscriptions (different delivery GUIDs for one comment), LB
double-forwards, and redeliveries all hit this.

Adds a bounded TTL first-seen cache in core and gates GitHub webhook
processing on a logical idempotency key (comment id + updated_at, so
edited comments still re-trigger), falling back to the X-GitHub-Delivery
GUID when the payload lacks comment identity. Fails open when neither is
available.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7f9ba644-14d1-4c9f-b3fd-22f9f841246d

📥 Commits

Reviewing files that changed from the base of the PR and between 0e470a9 and fae21fc.

📒 Files selected for processing (4)
  • packages/adapters/src/forge/github/adapter.test.ts
  • packages/adapters/src/forge/github/adapter.ts
  • packages/core/src/utils/delivery-dedup.test.ts
  • packages/core/src/utils/delivery-dedup.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/core/src/utils/delivery-dedup.test.ts
  • packages/adapters/src/forge/github/adapter.test.ts
  • packages/core/src/utils/delivery-dedup.ts
  • packages/adapters/src/forge/github/adapter.ts

📝 Walkthrough

Walkthrough

Adds a DeliveryDeduplicator utility, exports it from core, extends the GitHub webhook event type with comment id/updated_at, integrates deduplication into GitHubAdapter (handleWebhook now accepts deliveryId and drops repeats), passes x-github-delivery from the server, and adds tests covering dedup scenarios.

Changes

Webhook Delivery Deduplication

Layer / File(s) Summary
DeliveryDeduplicator core utility
packages/core/src/utils/delivery-dedup.ts, packages/core/src/utils/delivery-dedup.test.ts, packages/core/src/index.ts
New DeliveryDeduplicator implements TTL-based first-seen caching with insertion-ordered Map, expiry pruning, and size-based eviction; includes deterministic tests and is exported from core.
WebhookEvent type contract
packages/adapters/src/forge/github/types.ts
WebhookEvent.comment gains optional id and updated_at fields used to build dedup keys for comment identity and edits.
GitHubAdapter deduplication integration
packages/adapters/src/forge/github/adapter.ts
Adapter imports and instantiates DeliveryDeduplicator, adds private dedup state, extends handleWebhook to accept deliveryId, derives dedup key from comment id+updated_at or falls back to deliveryId, and returns early when a duplicate is detected.
Webhook endpoint wiring
packages/server/src/index.ts
Server webhook handler forwards x-github-delivery header as deliveryId to GitHubAdapter.handleWebhook.
GitHubAdapter dedup test coverage
packages/adapters/src/forge/github/adapter.test.ts
New test suite validates suppression and reprocessing across repeat deliveries, dual-subscription duplicates, edits, distinct comments, delivery GUID fallbacks, and fail-open behavior; includes adapter/test helpers.
Test script update
packages/core/package.json
packages/core test script replaced to run Bun test suites across core modules.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I watched the webhooks hop and stop the two-for-one,

A tiny dedup hop—now only single runs are spun,
Comments and deliveries checked with care and cheer,
One sighting, one run, no ghostly copies here.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(adapters): drop duplicate webhook deliveries at ingest' clearly and directly describes the main change: implementing deduplication to prevent duplicate webhook processing.
Description check ✅ Passed The description follows most of the template structure with clear Summary, Changes, Tests, and Validation sections. It provides detailed rationale, implementation details, test coverage, and validation evidence.
Linked Issues check ✅ Passed All coding requirements from #1951 are met: DeliveryDeduplicator implements bounded first-seen cache [#1951], GitHub adapter dedup logic gates processing with comment identity key [#1951], fallback to delivery GUID and fail-open behavior [#1951], dedup after signature verification [#1951], WebhookEvent.comment gains id/updated_at [#1951], and comprehensive tests cover all scenarios [#1951].
Out of Scope Changes check ✅ Passed All changes are tightly scoped to webhook deduplication requirements. The package.json test script update and core index.ts export are necessary infrastructure; gitea/gitlab adapters remain untouched as specified; no unrelated refactoring or feature additions detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/adapters/src/forge/github/adapter.test.ts`:
- Around line 470-527: The tests currently swallow errors from unmocked Octokit
calls in deliver(), making dedup tests non-deterministic; update
createDedupAdapter() to attach a mocked octokit client on the GitHubAdapter
instance that stubs the minimal downstream API methods used by handleWebhook
(e.g., the issues/comments and any repo/pull methods your webhook flow calls) so
calls resolve successfully, and then change deliver() to await
adapter.handleWebhook(...) without catching/ignoring errors so the test
deterministically fails on unexpected behavior; reference createDedupAdapter,
GitHubAdapter, and deliver when locating where to add the stubbed octokit
methods and remove the try/catch.

In `@packages/adapters/src/forge/github/adapter.ts`:
- Around line 1005-1010: The dedup key construction (dedupKey) currently treats
a comment identity as present when event.comment?.id exists even if
event.comment.updated_at is missing; change the condition that builds the
`comment:` key to require both `event.comment.id` and `event.comment.updated_at`
(non-null/undefined) before using them, otherwise fall back to the `deliveryId`
branch or undefined; update the ternary/condition around `dedupKey` (the
expression referencing `event.comment?.id` and `event.comment.updated_at`) so
edited-comment deduping only occurs when both values exist.

In `@packages/core/src/utils/delivery-dedup.test.ts`:
- Around line 3-79: Tests rely on real sleep/timers causing flakiness; switch to
Jest fake timers in the tests that use sleep so expiry is deterministic: in the
"key expires after TTL and may run again", "expired entries are pruned on
insert", and "re-seeing a key after expiry refreshes its eviction position"
tests, replace async await sleep(30) calls with jest.useFakeTimers() at test
start, call jest.advanceTimersByTime(30) (or
jest.runOnlyPendingTimers()/jest.runAllTimers() as needed) instead of awaiting
sleep, then call jest.useRealTimers() at the end; keep references to
DeliveryDeduplicator, seen, size, and remove reliance on the sleep helper (or
stub it to call advanceTimersByTime) so TTL behavior is driven by the fake clock
rather than wall-clock waits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5448b689-2aee-409a-9ecd-b9cc1dbdfaff

📥 Commits

Reviewing files that changed from the base of the PR and between ece2a68 and 0e470a9.

📒 Files selected for processing (8)
  • packages/adapters/src/forge/github/adapter.test.ts
  • packages/adapters/src/forge/github/adapter.ts
  • packages/adapters/src/forge/github/types.ts
  • packages/core/package.json
  • packages/core/src/index.ts
  • packages/core/src/utils/delivery-dedup.test.ts
  • packages/core/src/utils/delivery-dedup.ts
  • packages/server/src/index.ts

Comment on lines +470 to +527
function createDedupAdapter(): GitHubAdapter {
const adapter = new GitHubAdapter(
{ kind: 'pat', token: 'fake-token-for-testing' },
'fake-webhook-secret',
mockLockManager,
'archon'
);
// @ts-expect-error - accessing private method for testing
adapter.verifySignature = mock(() => true);
return adapter;
}

/**
* Comment payload carrying GitHub's comment identity (id + updated_at),
* as real issue_comment deliveries do.
*/
function createIdentifiedCommentPayload(
commentBody: string,
commentId: number | undefined,
updatedAt: string | undefined
): string {
const comment: {
id?: number;
body: string;
user: { login: string };
updated_at?: string;
} = { body: commentBody, user: { login: 'user123' } };
if (commentId !== undefined) comment.id = commentId;
if (updatedAt !== undefined) comment.updated_at = updatedAt;
return JSON.stringify({
action: 'created',
issue: {
number: 42,
title: 'Test Issue',
body: 'Description',
user: { login: 'user123' },
labels: [],
state: 'open',
},
comment,
repository: {
owner: { login: 'testuser' },
name: 'testrepo',
full_name: 'testuser/testrepo',
html_url: 'https://github.com/testuser/testrepo',
default_branch: 'main',
},
sender: { login: 'user123' },
});
}

async function deliver(adapter: GitHubAdapter, payload: string, deliveryId?: string) {
try {
await adapter.handleWebhook(payload, 'mock-signature', deliveryId);
} catch {
// Expected - Octokit API not mocked for the downstream message path.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Dedup tests depend on unmocked downstream Octokit failures.

Line 521-Line 526 swallows expected errors from an unmocked webhook path, which makes these tests depend on external call behavior instead of only dedup logic. Mock the minimal Octokit methods in createDedupAdapter() and make deliver() await success deterministically.

Suggested fix
     function createDedupAdapter(): GitHubAdapter {
       const adapter = new GitHubAdapter(
         { kind: 'pat', token: 'fake-token-for-testing' },
         'fake-webhook-secret',
         mockLockManager,
         'archon'
       );
       // `@ts-expect-error` - accessing private method for testing
       adapter.verifySignature = mock(() => true);
+      // `@ts-expect-error` - accessing private property for testing
+      adapter.octokit = {
+        rest: {
+          repos: {
+            get: mock(async () => ({ data: { default_branch: 'main' } })),
+          },
+          issues: {
+            listComments: mock(async () => ({ data: [] })),
+            createComment: mock(async () => ({ data: { id: 1 } })),
+          },
+        },
+      };
       return adapter;
     }
 
     async function deliver(adapter: GitHubAdapter, payload: string, deliveryId?: string) {
-      try {
-        await adapter.handleWebhook(payload, 'mock-signature', deliveryId);
-      } catch {
-        // Expected - Octokit API not mocked for the downstream message path.
-      }
+      await adapter.handleWebhook(payload, 'mock-signature', deliveryId);
     }

As per coding guidelines, “Keep tests deterministic — no flaky timing or network dependence without guardrails” and “mock external dependencies (database, AI SDKs, platform APIs).”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/adapters/src/forge/github/adapter.test.ts` around lines 470 - 527,
The tests currently swallow errors from unmocked Octokit calls in deliver(),
making dedup tests non-deterministic; update createDedupAdapter() to attach a
mocked octokit client on the GitHubAdapter instance that stubs the minimal
downstream API methods used by handleWebhook (e.g., the issues/comments and any
repo/pull methods your webhook flow calls) so calls resolve successfully, and
then change deliver() to await adapter.handleWebhook(...) without
catching/ignoring errors so the test deterministically fails on unexpected
behavior; reference createDedupAdapter, GitHubAdapter, and deliver when locating
where to add the stubbed octokit methods and remove the try/catch.

Source: Coding guidelines

Comment thread packages/adapters/src/forge/github/adapter.ts
Comment thread packages/core/src/utils/delivery-dedup.test.ts Outdated
…stic TTL tests

Keying on comment id alone (updated_at absent) would dedup an edit
against the original within the TTL window — require both fields and
use the delivery-GUID fallback otherwise. Dedup TTL tests now use an
injected clock instead of wall-clock sleeps.
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.

Duplicate webhook delivery runs a workflow twice (no ingest idempotency)

2 participants