Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 21 additions & 0 deletions packages/gittensory-miner/lib/claim-adjudication.d.ts
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions packages/gittensory-miner/lib/claim-adjudication.js
Original file line number Diff line number Diff line change
@@ -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),
};
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions test/unit/miner-claim-adjudication.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});