diff --git a/src/queue/processors.ts b/src/queue/processors.ts index a329004377..509b2c7174 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1399,7 +1399,7 @@ async function maybeRunAgentMaintenance( // reason ("duplicate of another open PR" via agent-actions when count > 0). When the flag is ON and this // PR is the cluster winner, force the count to 0 so the winner's close reason OMITS the duplicate cause // (it can still close on its own merits — CI/conflict/blockers). Flag-OFF short-circuits ⇒ the real - // count is used (byte-identical). Sparse legacy rows fall back to PR-number election. + // count is used (byte-identical). Sparse legacy rows fail closed so duplicate evidence remains visible. linkedDuplicateCount: dupWinnerLinkedDuplicateCount( linkedIssueDuplicatePullRequestRecordsForGate(pr, otherOpenPullRequests), pr.number, diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 563f606c86..3c2ecfac55 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -676,7 +676,8 @@ function addPullRequestFindings( ); // Duplicate-winner adjudication (#dup-winner): when the flag is ON and this PR is the earliest observed // linked-issue claimant, SKIP the duplicate finding — suppressing it suppresses the gate failure, so the - // winner survives while later claimants keep the finding. Sparse legacy rows fall back to PR-number election. + // winner survives while later claimants keep the finding. Sparse legacy rows fail closed instead of + // suppressing duplicate evidence with arbitrary PR-number ordering. // Flag-OFF (default) short-circuits ⇒ the finding is pushed exactly as before (byte-identical). if (overlappingPrs.length > 0 && !(duplicateWinnerEnabled && isDuplicateClusterWinnerByClaim(pr, overlappingPrs))) { findings.push({ diff --git a/src/signals/duplicate-winner.ts b/src/signals/duplicate-winner.ts index 0779256ecb..5c5e128868 100644 --- a/src/signals/duplicate-winner.ts +++ b/src/signals/duplicate-winner.ts @@ -4,7 +4,7 @@ * When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks + * auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is * spared: the earliest observed linked-issue claimant. Sparse legacy rows that do not yet have claim timing - * fall back to PR-number election so migrated clusters do not keep every sibling blocked. Only the LOSERS are + * fail closed so unknown ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are * blocked/closed; the winner still must pass CI / conflict / gate / linked-issue / slop on its OWN merits. * * This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the @@ -38,15 +38,15 @@ export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: n /** * True iff `pr` is the earliest known linked-issue claimant in the open duplicate cluster. Sparse legacy rows - * fall back to the original PR-number election; ties between known claim times also use PR number. + * fail closed; ties between known claim times use PR number. */ export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean { if (openSiblings.length === 0) return true; const prClaim = claimTimeMs(pr.linkedIssueClaimedAt); - if (prClaim === null) return isDuplicateClusterWinner(pr.number, openSiblings.map((sibling) => sibling.number)); + if (prClaim === null) return false; for (const sibling of openSiblings) { const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt); - if (siblingClaim === null) return isDuplicateClusterWinner(pr.number, openSiblings.map((other) => other.number)); + if (siblingClaim === null) return false; if (siblingClaim < prClaim) return false; if (siblingClaim === prClaim && sibling.number < pr.number) return false; } diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 120c0a1580..820a447b8d 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -4233,8 +4233,8 @@ export function buildPublicPrIntelligenceComment(args: { const hardLinkedIssueBlock = args.settings.linkedIssueGateMode === "block" && args.pr.linkedIssues.length === 0 && !hasClearNoIssueRationale(args.pr); // Duplicate-winner adjudication (#dup-winner): when the flag is ON and this PR is the earliest observed - // linked-issue claimant, do NOT hard-block it as a duplicate — only the losers block. Sparse legacy rows fall - // back to PR-number election so migrated clusters do not all stay blocked. + // linked-issue claimant, do NOT hard-block it as a duplicate — only the losers block. Sparse legacy rows fail + // closed so unknown ordering cannot suppress duplicate evidence. const hardDuplicateBlock = args.settings.duplicatePrGateMode === "block" && linkedDuplicatePrs.length > 0 && @@ -4473,7 +4473,7 @@ export function buildPublicPrPanelSignalRows(args: { const gateEnabled = args.settings.gateCheckMode === "enabled"; const hardLinkedIssueBlock = args.settings.linkedIssueGateMode === "block" && args.pr.linkedIssues.length === 0 && !hasClearNoIssueRationale(args.pr); // Duplicate-winner adjudication (#dup-winner): suppress the earliest known claimant's hard-duplicate block - // (see the comment builder). Sparse legacy rows fall back to PR-number election; flag-OFF keeps legacy behavior. + // (see the comment builder). Sparse legacy rows fail closed; flag-OFF keeps legacy behavior. const hardDuplicateBlock = args.settings.duplicatePrGateMode === "block" && linkedDuplicatePrs.length > 0 && diff --git a/test/unit/duplicate-winner.test.ts b/test/unit/duplicate-winner.test.ts index 85eb383834..856663a18c 100644 --- a/test/unit/duplicate-winner.test.ts +++ b/test/unit/duplicate-winner.test.ts @@ -51,13 +51,13 @@ describe("isDuplicateClusterWinnerByClaim (#dup-winner claim election)", () => { expect(isDuplicateClusterWinnerByClaim(claim(13, "2026-06-29T10:00:00.000Z"), [claim(12, "2026-06-29T10:00:00.000Z")])).toBe(false); }); - it("falls back to PR-number election when sparse legacy rows lack claim timestamps", () => { - expect(isDuplicateClusterWinnerByClaim(claim(12, null), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(true); + it("fails closed when sparse legacy rows lack claim timestamps", () => { + expect(isDuplicateClusterWinnerByClaim(claim(12, null), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(false); expect(isDuplicateClusterWinnerByClaim(claim(13, "2026-06-29T10:00:00.000Z"), [claim(12, null)])).toBe(false); }); - it("treats invalid claim timestamps as sparse rows for the PR-number fallback", () => { - expect(isDuplicateClusterWinnerByClaim(claim(12, "not-a-date"), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(true); + it("fails closed when sparse legacy rows have invalid claim timestamps", () => { + expect(isDuplicateClusterWinnerByClaim(claim(12, "not-a-date"), [claim(13, "2026-06-29T10:00:00.000Z")])).toBe(false); expect(isDuplicateClusterWinnerByClaim(claim(13, "2026-06-29T10:00:00.000Z"), [claim(12, "not-a-date")])).toBe(false); }); });