Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
159 changes: 159 additions & 0 deletions packages/adapters/src/forge/github/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,165 @@ describe('GitHubAdapter', () => {
});
});

describe('webhook delivery dedup', () => {
let originalAllowedUsers: string | undefined;

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.
}
}
Comment on lines +470 to +527

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


beforeEach(() => {
originalAllowedUsers = process.env.GITHUB_ALLOWED_USERS;
delete process.env.GITHUB_ALLOWED_USERS;
mockLockManager.acquireLock.mockClear();
mockGetOrCreateConversation.mockClear();
});

afterEach(() => {
if (originalAllowedUsers !== undefined) {
process.env.GITHUB_ALLOWED_USERS = originalAllowedUsers;
}
});

test('drops a repeat delivery of the same comment (same GUID)', async () => {
const adapter = createDedupAdapter();
const payload = createIdentifiedCommentPayload('@archon help', 1001, '2026-06-12T21:00:00Z');

await deliver(adapter, payload, 'guid-1');
await deliver(adapter, payload, 'guid-1');

expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(1);
});

test('drops a dual-subscription duplicate (same comment, different GUIDs)', async () => {
const adapter = createDedupAdapter();
const payload = createIdentifiedCommentPayload('@archon help', 1001, '2026-06-12T21:00:00Z');

// Repo webhook and App webhook deliver the same comment under different
// delivery GUIDs — the #1951 incident shape.
await deliver(adapter, payload, 'guid-repo-hook');
await deliver(adapter, payload, 'guid-app-hook');

expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(1);
});

test('processes an edited comment again (new updated_at)', async () => {
const adapter = createDedupAdapter();
const original = createIdentifiedCommentPayload('@archon help', 1001, '2026-06-12T21:00:00Z');
const edited = createIdentifiedCommentPayload(
'@archon help please',
1001,
'2026-06-12T21:05:00Z'
);

await deliver(adapter, original, 'guid-1');
await deliver(adapter, edited, 'guid-2');

expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(2);
});

test('processes distinct comments independently', async () => {
const adapter = createDedupAdapter();
const first = createIdentifiedCommentPayload('@archon help', 1001, '2026-06-12T21:00:00Z');
const second = createIdentifiedCommentPayload('@archon also', 1002, '2026-06-12T21:00:30Z');

await deliver(adapter, first, 'guid-1');
await deliver(adapter, second, 'guid-2');

expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(2);
});

test('requires both id and updated_at for the comment key (id alone uses GUID fallback)', async () => {
const adapter = createDedupAdapter();
// id present but updated_at missing — keying on id alone would dedup a
// later edit against the original, so this must use the GUID fallback.
const payload = createIdentifiedCommentPayload('@archon help', 1001, undefined);

await deliver(adapter, payload, 'guid-1');
await deliver(adapter, payload, 'guid-1');
await deliver(adapter, payload, 'guid-2');

// Same GUID deduped, different GUID processed (no comment-identity key).
expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(2);
});

test('falls back to delivery GUID when payload lacks comment id', async () => {
const adapter = createDedupAdapter();
const payload = createIdentifiedCommentPayload('@archon help', undefined, undefined);

await deliver(adapter, payload, 'guid-1');
await deliver(adapter, payload, 'guid-1');

expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(1);
});

test('fails open when neither comment id nor delivery GUID is available', async () => {
const adapter = createDedupAdapter();
const payload = createIdentifiedCommentPayload('@archon help', undefined, undefined);

await deliver(adapter, payload, undefined);
await deliver(adapter, payload, undefined);

// No key to dedup on — both deliveries process rather than risk drops.
expect(mockGetOrCreateConversation).toHaveBeenCalledTimes(2);
});
});

describe('conversationId format', () => {
test('should parse valid owner/repo#number format', async () => {
const mockCreateComment = mock(() => Promise.resolve({ data: {} }));
Expand Down
32 changes: 31 additions & 1 deletion packages/adapters/src/forge/github/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getLinkedIssueNumbers,
onConversationClosed,
ConversationLockManager,
DeliveryDeduplicator,
AppNotInstalledError,
installCredentialHelper,
} from '@archon/core';
Expand Down Expand Up @@ -66,6 +67,12 @@ export class GitHubAdapter implements IPlatformAdapter {
private allowedUsers: string[];
private botMention: string;
private lockManager: ConversationLockManager;
/**
* Ingest idempotency: drops repeat deliveries of one logical comment event
* (dual repo+App subscriptions, LB double-forwards, redeliveries) before
* they reach the lock manager, which orders but does not dedup (#1951).
*/
private readonly deliveryDedup = new DeliveryDeduplicator();
private readonly retryDelayFn: (attempt: number) => number;
/**
* Resolve the originating user's personal GitHub token (App mode only).
Expand Down Expand Up @@ -918,8 +925,10 @@ ${userComment}`;

/**
* Handle incoming webhook event
* @param deliveryId - GitHub's X-GitHub-Delivery GUID; dedup fallback when
* the payload carries no comment identity
*/
async handleWebhook(payload: string, signature: string): Promise<void> {
async handleWebhook(payload: string, signature: string, deliveryId?: string): Promise<void> {
// 1. Verify signature
if (!this.verifySignature(payload, signature)) {
getLog().error(
Expand Down Expand Up @@ -987,6 +996,27 @@ ${userComment}`;
// 5. Check @mention
if (!this.hasMention(comment)) return;

// 5a. Ingest idempotency. Key on the comment's identity (id + updated_at)
// rather than the delivery GUID: dual subscriptions (repo webhook + App
// webhook) deliver the same comment under different GUIDs, so GUID-only
// dedup misses the common duplicate source. An edited comment gets a new
// updated_at and forms a new key, so edit re-triggers still run. Both
// fields are required — keying on id alone would dedup an edit against
// the original. Falls back to the delivery GUID when either is absent.
const dedupKey =
event.comment?.id !== undefined && event.comment.updated_at
? `comment:${owner}/${repo}#${String(number)}:${String(event.comment.id)}:${event.comment.updated_at}`
: deliveryId
? `delivery:${deliveryId}`
: undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (dedupKey && this.deliveryDedup.seen(dedupKey)) {
getLog().info(
{ eventType, owner, repo, number, deliveryId },
'github.duplicate_delivery_dropped'
);
return;
}
Comment on lines +999 to +1027

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 | 🟡 Minor | ⚡ Quick win

Dedup marks the key as seen before downstream processing succeeds.

seen(dedupKey) records the key on first sight, then the handler proceeds through repo clone, branch resolution, and orchestration — any of which can throw. Because the webhook is fired-and-forget at the route (errors are only logged), a GitHub at-least-once redelivery (or LB retry) of the same comment within the 10-minute TTL will now be dropped, so a trigger that failed mid-processing is never re-attempted. This narrows the resilience the redelivery mechanism previously provided.

If recovering failed triggers via redelivery matters, consider recording the key only after the run is successfully enqueued/handled, or evicting the key on downstream failure.

The updated_at-required fix from the prior review is correctly applied here.

🧰 Tools
🪛 ESLint

[error] 1013-1013: Unsafe call of a type that could not be resolved.

(@typescript-eslint/no-unsafe-call)

🤖 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.ts` around lines 999 - 1018, The
dedup key in github/adapter.ts is being marked as seen too early in the webhook
flow, before clone/resolution/orchestration succeeds. Update the deliveryDedup
usage around the duplicate check in the handler so the key is only committed
after downstream processing has succeeded, or remove/evict it when the run
fails, to preserve redelivery retries for failed comment triggers. Refer to the
dedupKey / this.deliveryDedup.seen path in the GitHub adapter handler.


getLog().info({ eventType, owner, repo, number }, 'github.webhook_processing');

// 5b. Resolve GitHub login → Archon user (auto-create on first sight).
Expand Down
4 changes: 4 additions & 0 deletions packages/adapters/src/forge/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ export interface WebhookEvent {
deletions?: number;
};
comment?: {
/** GitHub's stable comment id — same across duplicate deliveries of one comment */
id?: number;
body: string;
user: { login: string };
/** Bumped on edit, so an edited comment forms a new idempotency key */
updated_at?: string;
};
repository: {
owner: { login: string };
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"./schemas/*": "./src/schemas/*.ts"
},
"scripts": {
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/bundled-schema.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/users.test.ts src/db/workflow-events.test.ts src/db/workflow-node-sessions.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/token-crypto.test.ts && bun test src/db/workflows.resume-cas.integration.test.ts && bun test src/db/workflow-events.since.integration.test.ts && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/manage-run-tool.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/post-message-reminder.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/github-auth/auth.test.ts && bun test src/db/user-github-token-store.test.ts && bun test src/github-auth/device-flow.test.ts && bun test src/github-auth/config.test.ts && bun test src/github-auth/connect-service.test.ts && bun test src/db/user-provider-key-store.test.ts && bun test src/db/user-ai-prefs-store.test.ts && bun test src/credentials/connect-service.test.ts && bun test src/credentials/oauth-bridge.test.ts && bun test src/credentials/config.test.ts src/credentials/delivery.test.ts src/credentials/openai-oauth.test.ts src/credentials/catalog.test.ts",
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/bundled-schema.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/users.test.ts src/db/workflow-events.test.ts src/db/workflow-node-sessions.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/delivery-dedup.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/token-crypto.test.ts && bun test src/db/workflows.resume-cas.integration.test.ts && bun test src/db/workflow-events.since.integration.test.ts && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/manage-run-tool.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/post-message-reminder.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/github-auth/auth.test.ts && bun test src/db/user-github-token-store.test.ts && bun test src/github-auth/device-flow.test.ts && bun test src/github-auth/config.test.ts && bun test src/github-auth/connect-service.test.ts && bun test src/db/user-provider-key-store.test.ts && bun test src/db/user-ai-prefs-store.test.ts && bun test src/credentials/connect-service.test.ts && bun test src/credentials/oauth-bridge.test.ts && bun test src/credentials/config.test.ts src/credentials/delivery.test.ts src/credentials/openai-oauth.test.ts src/credentials/catalog.test.ts",
"type-check": "bun x tsc --noEmit",
"build": "echo 'No build needed - Bun runs TypeScript directly'"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ export {
// Conversation lock
export { ConversationLockManager, type LockAcquisitionResult } from './utils/conversation-lock';

// Webhook delivery dedup
export { DeliveryDeduplicator } from './utils/delivery-dedup';

// Error formatting
export { classifyAndFormatError } from './utils/error-formatter';
export { toError } from './utils/error';
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/utils/delivery-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { DeliveryDeduplicator } from './delivery-dedup';

/** Deterministic clock: tests advance time explicitly instead of sleeping. */
function createClock(start = 0) {
let now = start;
return {
now: () => now,
advance: (ms: number) => {
now += ms;
},
};
}

describe('DeliveryDeduplicator', () => {
test('first sighting of a key is not a duplicate', () => {
const dedup = new DeliveryDeduplicator();
expect(dedup.seen('comment:owner/repo#42:100:2026-06-12T00:00:00Z')).toBe(false);
});

test('repeat sighting within TTL is a duplicate', () => {
const dedup = new DeliveryDeduplicator();
const key = 'comment:owner/repo#42:100:2026-06-12T00:00:00Z';
expect(dedup.seen(key)).toBe(false);
expect(dedup.seen(key)).toBe(true);
expect(dedup.seen(key)).toBe(true);
});

test('different keys do not collide', () => {
const dedup = new DeliveryDeduplicator();
expect(dedup.seen('comment:owner/repo#42:100:t1')).toBe(false);
expect(dedup.seen('comment:owner/repo#42:101:t1')).toBe(false);
expect(dedup.seen('comment:owner/repo#43:100:t1')).toBe(false);
});

test('same comment with new updated_at is not a duplicate (edit re-trigger)', () => {
const dedup = new DeliveryDeduplicator();
expect(dedup.seen('comment:owner/repo#42:100:2026-06-12T00:00:00Z')).toBe(false);
expect(dedup.seen('comment:owner/repo#42:100:2026-06-12T00:05:00Z')).toBe(false);
});

test('key expires after TTL and may run again', () => {
const clock = createClock();
const dedup = new DeliveryDeduplicator(20, 10_000, clock.now);
const key = 'delivery:guid-1';
expect(dedup.seen(key)).toBe(false);
expect(dedup.seen(key)).toBe(true);

clock.advance(30);

expect(dedup.seen(key)).toBe(false);
expect(dedup.seen(key)).toBe(true);
});

test('key just inside the TTL window is still a duplicate', () => {
const clock = createClock();
const dedup = new DeliveryDeduplicator(20, 10_000, clock.now);
expect(dedup.seen('k')).toBe(false);

clock.advance(19);

expect(dedup.seen('k')).toBe(true);
});

test('expired entries are pruned on insert', () => {
const clock = createClock();
const dedup = new DeliveryDeduplicator(20, 10_000, clock.now);
dedup.seen('a');
dedup.seen('b');
expect(dedup.size).toBe(2);

clock.advance(30);

dedup.seen('c');
expect(dedup.size).toBe(1); // a and b expired and pruned
});

test('evicts oldest entries past max size', () => {
const dedup = new DeliveryDeduplicator(60_000, 3);
dedup.seen('a');
dedup.seen('b');
dedup.seen('c');
dedup.seen('d'); // evicts a

expect(dedup.size).toBe(3);
expect(dedup.seen('a')).toBe(false); // evicted, so first-seen again
expect(dedup.seen('d')).toBe(true); // still tracked
});

test('re-seeing a key after expiry refreshes its eviction position', () => {
const clock = createClock();
const dedup = new DeliveryDeduplicator(20, 10, clock.now);
dedup.seen('a');
clock.advance(30);
dedup.seen('b');
dedup.seen('a'); // expired -> refreshed, moves behind b

expect(dedup.seen('a')).toBe(true);
expect(dedup.seen('b')).toBe(true);
expect(dedup.size).toBe(2);
});
});
Loading
Loading