From 04b934a74d7c4615d2fa1d4a4f45b6203b2c62bf Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:25:28 +0900 Subject: [PATCH] feat(miner): add a `claim reclaim` CLI to expire orphaned soft-claims ClaimLedger.reclaimExpiredClaims existed to expire claims stranded active by a crashed/killed attempt, but had no production caller -- unlike its named model reclaimStuckItems, which both runs automatically inside claimNextBatch AND has an operator escape hatch (`queue release`). A claim left active by a SIGKILLed attempt therefore counts against the repo's maxConcurrentClaims cap for up to the 14-day default window, with no command to clear it. Add `loopover-miner claim reclaim [--max-age-ms ] [--dry-run] [--json]`, mirroring runQueueRelease's parse -> dry-run-short-circuit -> withStore -> reportCliFailure shape: - Calls ClaimLedger.reclaimExpiredClaims(maxAgeMs), passing --max-age-ms when supplied and omitting it so the ledger's own DEFAULT_MAX_CLAIM_AGE_MS applies. - --max-age-ms accepts only a finite integer >= 0; anything else returns usage. - --dry-run prints what would be reclaimed and returns 0 without opening the ledger. - --json prints { "reclaimed": [...] }; plain text prints one line per reclaimed claim plus a count, or `none`. Exit 0 either way -- reclaiming nothing is not a failure. - Registered in runClaimCli's dispatch and printHelp alongside claim/release/list. Tests cover every new branch: max-age supplied/omitted, valid/invalid, dry-run on/off (asserting the ledger is never opened via an injected opener), json on/off, reclaimed-empty/nonempty, and dispatcher reachability. Closes #9686 --- .../loopover-miner/lib/claim-ledger-cli.ts | 91 ++++++++++++++ packages/loopover-miner/lib/cli.ts | 1 + test/unit/miner-claim-ledger-cli.test.ts | 116 ++++++++++++++++++ 3 files changed, 208 insertions(+) diff --git a/packages/loopover-miner/lib/claim-ledger-cli.ts b/packages/loopover-miner/lib/claim-ledger-cli.ts index aca6b8a0c2..9de4d9aa89 100644 --- a/packages/loopover-miner/lib/claim-ledger-cli.ts +++ b/packages/loopover-miner/lib/claim-ledger-cli.ts @@ -9,6 +9,8 @@ const CLAIM_RELEASE_USAGE = "Usage: loopover-miner claim release [--api-base-url ] [--dry-run] [--json]"; const CLAIM_LIST_USAGE = "Usage: loopover-miner claim list [--repo ] [--status active|released|expired] [--json]"; +const CLAIM_RECLAIM_USAGE = + "Usage: loopover-miner claim reclaim [--max-age-ms ] [--dry-run] [--json]"; export type ParsedClaimClaimArgs = | { @@ -39,6 +41,14 @@ export type ParsedClaimListArgs = } | { error: string }; +export type ParsedClaimReclaimArgs = + | { + maxAgeMs: number | undefined; + dryRun: boolean; + json: boolean; + } + | { error: string }; + export type ClaimLedgerCliOptions = { openClaimLedger?: () => ClaimLedger }; type ParsedRepoArg = { repoFullName: string } | { error: string }; @@ -361,9 +371,90 @@ export function runClaimList(args: string[], options: ClaimLedgerCliOptions = {} } } +export function parseClaimReclaimArgs(args: string[]): ParsedClaimReclaimArgs { + const options: { json: boolean; dryRun: boolean; maxAgeMs: number | undefined } = { + json: false, + dryRun: false, + maxAgeMs: undefined, + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--max-age-ms") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: CLAIM_RECLAIM_USAGE }; + } + const parsed = Number(value); + // Only a finite integer >= 0 is a valid claim age; reject fractional, negative, and non-numeric input so + // a typo can never be silently coerced into an unbounded or nonsensical reclaim window. + if (!Number.isInteger(parsed) || parsed < 0) { + return { error: CLAIM_RECLAIM_USAGE }; + } + options.maxAgeMs = parsed; + index += 1; + continue; + } + return { error: token.startsWith("-") ? `Unknown option: ${token}` : CLAIM_RECLAIM_USAGE }; + } + + return { maxAgeMs: options.maxAgeMs, dryRun: options.dryRun, json: options.json }; +} + +/** `reclaim [--max-age-ms ] [--dry-run] [--json]`: expire claims orphaned by a crashed/killed attempt, + * the manual counterpart to the automatic sweep claimIssue runs (mirrors `queue release` for the analogous + * portfolio-queue lease). Omitting --max-age-ms uses the ledger's own DEFAULT_MAX_CLAIM_AGE_MS. Exit 0 even + * when nothing is over-age -- reclaiming nothing is not a failure. */ +export function runClaimReclaim(args: string[], options: ClaimLedgerCliOptions = {}): number { + const parsed = parseClaimReclaimArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + // Short-circuit BEFORE withClaimLedger so a dry run never opens the ledger at all, matching runQueueRelease. + const dryRunResult = { outcome: "dry_run", maxAgeMs: parsed.maxAgeMs ?? null }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + const window = parsed.maxAgeMs === undefined ? "the default max age" : `${parsed.maxAgeMs}ms`; + console.log(`DRY RUN: would reclaim claims older than ${window}. No claim-ledger write was made.`); + } + return 0; + } + + try { + return withClaimLedger(options, (claimLedger) => { + const reclaimed = claimLedger.reclaimExpiredClaims(parsed.maxAgeMs); + if (parsed.json) { + console.log(JSON.stringify({ reclaimed }, null, 2)); + } else if (reclaimed.length === 0) { + console.log("none"); + } else { + for (const claim of reclaimed) { + console.log(`${claim.repoFullName}#${claim.issueNumber} ${claim.status}`); + } + console.log(`reclaimed ${reclaimed.length}`); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + export function runClaimCli(subcommand: string | undefined, args: string[], options: ClaimLedgerCliOptions = {}): number { if (subcommand === "claim") return runClaimClaim(args, options); if (subcommand === "release") return runClaimRelease(args, options); if (subcommand === "list") return runClaimList(args, options); + if (subcommand === "reclaim") return runClaimReclaim(args, options); return reportCliFailure(argsWantJson(args), `Unknown claim subcommand: ${subcommand ?? ""}. ${CLAIM_LIST_USAGE}`); } diff --git a/packages/loopover-miner/lib/cli.ts b/packages/loopover-miner/lib/cli.ts index 21fcefc0f2..977091ef8f 100644 --- a/packages/loopover-miner/lib/cli.ts +++ b/packages/loopover-miner/lib/cli.ts @@ -43,6 +43,7 @@ export function printHelp(input: { packageName: string }): void { " loopover-miner claim claim [--note ] [--dry-run] [--json]", " loopover-miner claim release [--dry-run] [--json]", " loopover-miner claim list [--repo ] [--status active|released|expired] [--json]", + " loopover-miner claim reclaim [--max-age-ms ] [--dry-run] [--json] Expire claims orphaned by a killed attempt", " loopover-miner ledger list [--repo ] [--since ] [--type ] [--json]", " loopover-miner ledger metrics Print event-ledger counters in Prometheus text format", " loopover-miner plan list [--status pending|running|completed|failed] [--json]", diff --git a/test/unit/miner-claim-ledger-cli.test.ts b/test/unit/miner-claim-ledger-cli.test.ts index 91dbc261b7..38d12538e9 100644 --- a/test/unit/miner-claim-ledger-cli.test.ts +++ b/test/unit/miner-claim-ledger-cli.test.ts @@ -10,13 +10,16 @@ import type { ClaimEntry } from "../../packages/loopover-miner/lib/claim-ledger. import { parseClaimClaimArgs, parseClaimListArgs, + parseClaimReclaimArgs, parseClaimReleaseArgs, renderClaimsTable, runClaimClaim, runClaimCli, runClaimList, + runClaimReclaim, runClaimRelease, } from "../../packages/loopover-miner/lib/claim-ledger-cli"; +import { DEFAULT_MAX_CLAIM_AGE_MS } from "../../packages/loopover-miner/lib/claim-ledger-expiry"; const roots: string[] = []; const ledgers: Array<{ close(): void }> = []; @@ -33,6 +36,7 @@ afterEach(() => { for (const ledger of ledgers.splice(0)) ledger.close(); closeDefaultClaimLedger(); vi.restoreAllMocks(); + vi.useRealTimers(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -452,3 +456,115 @@ describe("loopover-miner claim ledger CLI (#4290)", () => { } }); }); + +describe("loopover-miner claim reclaim (#9686)", () => { + const CLAIM_RECLAIM_USAGE = + "Usage: loopover-miner claim reclaim [--max-age-ms ] [--dry-run] [--json]"; + + it("parseClaimReclaimArgs validates --max-age-ms, --dry-run, and --json", () => { + expect(parseClaimReclaimArgs([])).toEqual({ maxAgeMs: undefined, dryRun: false, json: false }); + expect(parseClaimReclaimArgs(["--max-age-ms", "1000", "--dry-run", "--json"])).toEqual({ + maxAgeMs: 1000, + dryRun: true, + json: true, + }); + // 0 is a valid age (reclaim everything); the lower bound is inclusive. + expect(parseClaimReclaimArgs(["--max-age-ms", "0"])).toEqual({ maxAgeMs: 0, dryRun: false, json: false }); + // Fractional, negative, non-numeric, and a missing value all return the usage string. + expect(parseClaimReclaimArgs(["--max-age-ms", "1.5"])).toEqual({ error: CLAIM_RECLAIM_USAGE }); + expect(parseClaimReclaimArgs(["--max-age-ms", "-1"])).toEqual({ error: CLAIM_RECLAIM_USAGE }); + expect(parseClaimReclaimArgs(["--max-age-ms", "soon"])).toEqual({ error: CLAIM_RECLAIM_USAGE }); + expect(parseClaimReclaimArgs(["--max-age-ms"])).toEqual({ error: CLAIM_RECLAIM_USAGE }); + expect(parseClaimReclaimArgs(["--nope"])).toEqual({ error: "Unknown option: --nope" }); + expect(parseClaimReclaimArgs(["extra"])).toEqual({ error: CLAIM_RECLAIM_USAGE }); + }); + + it("reclaims an over-age active claim, transitioning it to expired (default window, --max-age-ms omitted)", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const claimLedger = tempClaimLedger(); + claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 7 }); + // Move the clock one hour past the default window so the claim is over-age. + vi.setSystemTime(new Date(Date.parse("2026-01-01T00:00:00.000Z") + DEFAULT_MAX_CLAIM_AGE_MS + 60 * 60 * 1000)); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runClaimReclaim([], { openClaimLedger: () => claimLedger })).toBe(0); + expect(log).toHaveBeenCalledWith("acme/widgets#7 expired"); + expect(log).toHaveBeenCalledWith("reclaimed 1"); + expect(claimLedger.listClaims({ status: "expired" }).map((c) => c.issueNumber)).toEqual([7]); + }); + + it("prints 'none' and exits 0 when nothing is over-age", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const claimLedger = tempClaimLedger(); + claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 7 }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + // Still inside the default window -- reclaiming nothing is not a failure. + expect(runClaimReclaim([], { openClaimLedger: () => claimLedger })).toBe(0); + expect(log).toHaveBeenCalledWith("none"); + expect(claimLedger.listClaims({ status: "expired" })).toEqual([]); + }); + + it("honours an explicit smaller --max-age-ms, reclaiming a claim inside the default window", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const claimLedger = tempClaimLedger(); + claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 7 }); + // Two hours later: well inside the 14-day default window, but past an explicit 1-hour window. + vi.setSystemTime(new Date(Date.parse("2026-01-01T00:00:00.000Z") + 2 * 60 * 60 * 1000)); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runClaimReclaim(["--max-age-ms", String(60 * 60 * 1000), "--json"], { openClaimLedger: () => claimLedger })).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + reclaimed: [expect.objectContaining({ repoFullName: "acme/widgets", issueNumber: 7, status: "expired" })], + }); + }); + + it("an invalid --max-age-ms returns the usage error without opening the ledger", () => { + const openClaimLedgerSpy = vi.fn(); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runClaimReclaim(["--max-age-ms", "-5"], { openClaimLedger: openClaimLedgerSpy })).toBe(2); + expect(errorLog).toHaveBeenCalledWith(CLAIM_RECLAIM_USAGE); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + }); + + it("--dry-run returns 0 without opening the claim ledger (plain and --json)", () => { + const openClaimLedgerSpy = vi.fn(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runClaimReclaim(["--dry-run"], { openClaimLedger: openClaimLedgerSpy })).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("DRY RUN: would reclaim claims older than the default max age. No claim-ledger write was made."); + + // Explicit --max-age-ms in the plain-text dry-run path names the exact window (the defined ternary arm). + log.mockClear(); + expect(runClaimReclaim(["--dry-run", "--max-age-ms", "5000"], { openClaimLedger: openClaimLedgerSpy })).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("DRY RUN: would reclaim claims older than 5000ms. No claim-ledger write was made."); + + log.mockClear(); + expect(runClaimReclaim(["--dry-run", "--max-age-ms", "1000", "--json"], { openClaimLedger: openClaimLedgerSpy })).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ outcome: "dry_run", maxAgeMs: 1000 }); + }); + + it("surfaces a ledger error through reportCliFailure (--json)", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const thrower = () => { + throw new Error("ledger boom"); + }; + expect(runClaimReclaim(["--json"], { openClaimLedger: thrower as never })).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ ok: false }); + }); + + it("runClaimCli dispatches the reclaim subcommand", () => { + const openClaimLedgerSpy = vi.fn(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + // Reachable from the dispatcher: a dry-run proves the reclaim path ran (never opens the ledger). + expect(runClaimCli("reclaim", ["--dry-run"], { openClaimLedger: openClaimLedgerSpy })).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("DRY RUN: would reclaim claims older than the default max age. No claim-ledger write was made."); + }); +});