Skip to content

fix(dup-winner): elect duplicate-cluster winner by GitHub's true PR creation time, not gittensory's own sync-observation time #3816

Description

@JSONbored

Context

Found during the 2026-07-06 incident audit (parent: #1667). This is the most direct fix for the user-facing complaint: contributors should be credited properly when someone links/closes the same issue after someone else already did the same or similar work.

Verified against src/signals/duplicate-winner.ts and src/db/repositories.ts (upsertPullRequestFromGitHub, resolveLinkedIssueClaimedAt) as they exist today.

isDuplicateClusterWinnerByClaim elects the earliest linkedIssueClaimedAt as the surviving PR in a duplicate cluster (all other open siblings linking the same issue get gate-blocked + closed). But linkedIssueClaimedAt is not GitHub's true PR-creation time — it is set to observedLinkedIssueClaimedAt = syncedAt (repositories.ts:321,343), i.e. the wall-clock instant gittensory itself first synced/processed that PR's linked-issue body. Two real PRs opened in the true order A (10:00) then B (10:05), where gittensory's webhook/backfill processing happens to observe B before A (queue reordering, backfill catch-up, webhook delivery delay, or exactly today's incident where a sweep stalled for hours and then processed a backlog in non-creation order), get linkedIssueClaimedAt values in the REVERSE of their true creation order. B — the later contributor — wins the cluster and A gets closed as the 'duplicate,' even though A opened their PR first and did the original work.

This is exactly the failure mode the user flagged: silent mis-credit when processing order and true creation order diverge, which today's incident (hours-long sweep stalls, backlog processed out of order) makes far more likely than the module's own docstring assumed.

GitHubPullRequestPayload.created_at (src/types.ts:301) already carries GitHub's authoritative PR creation timestamp and is already stored in each PR's payloadJson — it is simply never surfaced as its own comparable field or used in the winner election.

Requirements

  • Add a durable createdAt (or reuse/expose the existing GitHub created_at) field alongside linkedIssueClaimedAt on the PR record, sourced from pr.created_at in upsertPullRequestFromGitHub — this value is immutable per-PR (GitHub never changes a PR's creation time), so no resolveXxx-style existing-vs-observed merge logic is needed; it can be written once at first-seen and never touched again.
  • Change isDuplicateClusterWinnerByClaim (or add a new claim-comparison strategy alongside it) to elect the winner by true PR creation time when available, falling back to the existing linkedIssueClaimedAt-based logic only for legacy rows that predate this field.
  • Preserve the existing fail-closed behavior for sparse/missing data: a PR or sibling with no resolvable creation time should not be able to win by default over ones that do.
  • Update openSiblingsForPr (the processors.ts:7275 call site) to fetch/pass creation time alongside linkedIssueClaimedAt.
  • Add regression tests: (a) two PRs where gittensory observes/syncs the later-created PR first — assert the earlier-created PR still wins; (b) confirm existing claim-time-based tests for legacy rows without creation time still pass unchanged; (c) tie-break on identical creation-time falls back to PR number, matching current behavior.

Deliverables

  • src/db/schema.ts: new column (e.g. createdAt sourced from GitHub, distinct from linkedIssueClaimedAt) + migration.
  • src/db/repositories.ts: populate the new column at PR upsert time from pr.created_at.
  • src/signals/duplicate-winner.ts: creation-time-first election logic, with a documented, tested fallback to claim-time for legacy data.
  • src/queue/processors.ts: thread the new field through openSiblingsForPr/the duplicate-winner call site.
  • test/unit/duplicate-winner.test.ts (or equivalent): new cases per above.

Expected outcome

The surviving PR in a duplicate cluster is the one whose author actually opened their PR first on GitHub, regardless of the order gittensory's own webhook/sweep/backfill pipeline happened to observe or process them in — removing a silent mis-credit failure mode that today's incident made concretely more likely to occur.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

maintainer-onlyOwner-only work — yields no Gittensor points.

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions