diff --git a/packages/gittensory-miner/lib/claim-adjudication.d.ts b/packages/gittensory-miner/lib/claim-adjudication.d.ts new file mode 100644 index 0000000000..e195bc19aa --- /dev/null +++ b/packages/gittensory-miner/lib/claim-adjudication.d.ts @@ -0,0 +1,21 @@ +/** An observed claim on an issue: a PR/claimant number plus when it claimed the linked issue (if known). */ +export type ObservedClaim = { + number: number; + claimedAt?: string | null | undefined; +}; + +/** The engine `DuplicateClaimMember` shape this module bridges an {@link ObservedClaim} to. */ +export type ClaimMember = { + number: number; + linkedIssueClaimedAt: string | null; +}; + +/** The adjudication result: the go/no-go `isWinner`, plus a DISPLAY-only `winnerNumber` (null when not determinable). */ +export type ClaimAdjudication = { + isWinner: boolean; + winnerNumber: number | null; +}; + +export function toClaimMember(claim: ObservedClaim): ClaimMember; + +export function adjudicateSoftClaim(self: ObservedClaim, competing?: readonly ObservedClaim[]): ClaimAdjudication; diff --git a/packages/gittensory-miner/lib/claim-adjudication.js b/packages/gittensory-miner/lib/claim-adjudication.js new file mode 100644 index 0000000000..fe99581bd8 --- /dev/null +++ b/packages/gittensory-miner/lib/claim-adjudication.js @@ -0,0 +1,36 @@ +// Soft-claim adjudication (#4291). Decides which of several miners claiming the same issue proceeds, by REUSING the +// maintainer-side duplicate-cluster election (`isDuplicateClusterWinnerByClaim` from @jsonbored/gittensory-engine) +// rather than reimplementing it — so the miner and the maintainer gate agree on exactly one winner by construction. +// +// The local claim ledger is 100% client-side and cannot see other miners' claims, so the competing-claim signal +// must come from something publicly observable: the OPEN PRs that link the same issue (an issue with several open +// PRs linking it IS the public signal of a contested claim). The caller assembles that set — exactly like the +// maintainer-side callers in src/ do — and passes it here. +import { isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "@jsonbored/gittensory-engine"; + +/** + * Map an observed claim record to the engine's `DuplicateClaimMember`. The field names deliberately DIFFER — the + * local ledger / observed data expose `claimedAt`, the engine election reads `linkedIssueClaimedAt` — so the bridge + * is explicit (they are not interchangeable by accident of naming). `createdAt` is intentionally omitted: the + * election ignores it (an older PR can claim a linked issue later by editing its body). Pure. + */ +export function toClaimMember(claim) { + return { number: claim.number, linkedIssueClaimedAt: claim.claimedAt ?? null }; +} + +/** + * Adjudicate whether THIS miner's soft-claim wins a contested issue. `self` is this miner's claim and `competing` + * is the publicly-observable set of OTHER open PRs linking the same issue; each entry is `{ number, claimedAt }`. + * Returns the go/no-go `isWinner` (driven ONLY by `isDuplicateClusterWinnerByClaim`) plus a DISPLAY-only + * `winnerNumber` (from `resolveDuplicateClusterWinnerNumber`, for surfacing "you lost this claim to PR #N" to the + * operator — never for the decision). Pure — no IO. Fail-closed: a missing/sparse claim time loses; the winner is + * `null` when the ordering is too sparse to be sure (it never guesses). An empty `competing` list ⇒ trivial winner. + */ +export function adjudicateSoftClaim(self, competing = []) { + const selfMember = toClaimMember(self); + const siblings = competing.map(toClaimMember); + return { + isWinner: isDuplicateClusterWinnerByClaim(selfMember, siblings), + winnerNumber: resolveDuplicateClusterWinnerNumber(selfMember, siblings), + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 7aba5d88d3..a810c4cc3a 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" }, "dependencies": { "@jsonbored/gittensory-engine": ">=0.1.0 <1.0.0" diff --git a/test/unit/miner-claim-adjudication.test.ts b/test/unit/miner-claim-adjudication.test.ts new file mode 100644 index 0000000000..8d062f5dcf --- /dev/null +++ b/test/unit/miner-claim-adjudication.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { adjudicateSoftClaim, toClaimMember } from "../../packages/gittensory-miner/lib/claim-adjudication.js"; + +describe("miner soft-claim adjudication (#4291)", () => { + it("toClaimMember maps claimedAt → linkedIssueClaimedAt (the field names deliberately differ)", () => { + expect(toClaimMember({ number: 7, claimedAt: "2026-01-01T00:00:00Z" })).toEqual({ + number: 7, + linkedIssueClaimedAt: "2026-01-01T00:00:00Z", + }); + // a missing claim time maps to null (fail-closed input), not undefined + expect(toClaimMember({ number: 7 })).toEqual({ number: 7, linkedIssueClaimedAt: null }); + }); + + it("this miner WINS when it claimed earliest", () => { + const result = adjudicateSoftClaim( + { number: 5, claimedAt: "2026-01-01T00:00:00Z" }, + [{ number: 6, claimedAt: "2026-01-02T00:00:00Z" }], + ); + expect(result).toEqual({ isWinner: true, winnerNumber: 5 }); + }); + + it("this miner LOSES to an earlier competing claim, and the winner is surfaced (display only)", () => { + const result = adjudicateSoftClaim( + { number: 6, claimedAt: "2026-01-02T00:00:00Z" }, + [{ number: 5, claimedAt: "2026-01-01T00:00:00Z" }], + ); + expect(result).toEqual({ isWinner: false, winnerNumber: 5 }); + }); + + it("no competing claim ⇒ trivial winner (even with an unknown claim time)", () => { + expect(adjudicateSoftClaim({ number: 9, claimedAt: "2026-03-01T00:00:00Z" }, [])).toEqual({ + isWinner: true, + winnerNumber: 9, + }); + expect(adjudicateSoftClaim({ number: 9 }, [])).toEqual({ isWinner: true, winnerNumber: 9 }); + }); + + it("fail-closed: a missing/sparse claim time loses AND yields no guessed winner", () => { + // this miner has no observed claim time → cannot be elected, and (because the election needs BOTH members' + // times to order them) no winner is determinable either — the engine never guesses when data is too sparse. + expect(adjudicateSoftClaim({ number: 6 }, [{ number: 5, claimedAt: "2026-01-01T00:00:00Z" }])).toEqual({ + isWinner: false, + winnerNumber: null, + }); + // BOTH sides sparse → still no determinable winner + expect(adjudicateSoftClaim({ number: 6 }, [{ number: 5 }])).toEqual({ isWinner: false, winnerNumber: null }); + }); +});