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.

Metadata

Metadata

Assignees

Labels

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

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions