⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
ClaimLedger.reclaimExpiredClaims(maxAgeMs?) in packages/loopover-miner/lib/claim-ledger.ts exists to expire claims orphaned by a crashed/killed process, and its doc comment names its intended shape:
/** Expire claims orphaned by a crashed/killed process, returning the transitioned rows (#6156). The explicit
* counterpart to the sweep claimIssue runs on its own, mirroring reclaimStuckItems (portfolio-queue-manager.js). */
Its named model, reclaimStuckItems, genuinely is wired: it runs automatically inside claimNextBatch (portfolio-queue-manager.ts:174) and has an operator-facing manual escape hatch, loopover-miner queue release (portfolio-queue-cli.ts:398-430).
reclaimExpiredClaims has neither. grep -rn reclaimExpiredClaims packages/ test/ src/ apps/ returns only its own declaration, its own implementation, and test/unit/miner-claim-ledger-sweep-wiring.test.ts. The claim CLI dispatches exactly three subcommands — claim, release, list (claim-ledger-cli.ts:365-367) — with no reclaim/expire path, and neither cli.ts's help text nor the docs mention one.
The operator-visible gap: the automatic sweep inside claimIssue/claimIssueWithinCap hardcodes DEFAULT_MAX_CLAIM_AGE_MS = 14 days (claim-ledger-expiry.ts:5) and only ever runs when a new claim is attempted on some repo. A claim stranded active by a SIGKILLed attempt therefore counts against that repo's maxConcurrentClaims cap (claim-ledger.ts:341-370) for up to two weeks, and there is no command an operator can run to clear it — while the exactly analogous portfolio-queue lease is both swept at 30 minutes and manually releasable.
Requirements
- Add a
reclaim subcommand to the claim CLI: loopover-miner claim reclaim [--max-age-ms <n>] [--dry-run] [--json].
- It must call
ClaimLedger.reclaimExpiredClaims(maxAgeMs), passing the parsed --max-age-ms when supplied and omitting it (so the ledger's own DEFAULT_MAX_CLAIM_AGE_MS default applies) when not.
--max-age-ms must accept only a finite integer at or above 0; anything else returns the subcommand's usage string through reportCliFailure.
--dry-run must print what would be reclaimed and return 0 without opening the claim ledger at all, matching runQueueRelease's dry-run shape (portfolio-queue-cli.ts:404-412).
- Non-dry-run output:
--json prints { "reclaimed": [...] } (the returned ClaimEntry[]); plain text prints one line per reclaimed claim plus a count, and none when nothing was reclaimed. Exit 0 in both cases — reclaiming nothing is not a failure.
- Register
reclaim in runClaimCli's dispatch alongside claim/release/list, and add the new usage line to printHelp in packages/loopover-miner/lib/cli.ts next to the other claim lines.
⚠️ Required pattern: mirror runQueueRelease in packages/loopover-miner/lib/portfolio-queue-cli.ts:396-430 — the same parse -> dry-run short-circuit -> withStore -> reportCliFailure(describeCliError(error)) structure and the same argsWantJson parse-error handling used by every other subcommand in claim-ledger-cli.ts. It does NOT satisfy this issue to call reclaimExpiredClaims from runClaimClaim/runClaimList as a side effect, to add it as a doctor check, to add an MCP tool, or to shorten DEFAULT_MAX_CLAIM_AGE_MS instead of adding the command.
Deliverables
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example adding runClaimReclaim without registering it in runClaimCli's dispatch and in printHelp, leaving it as unreachable as reclaimExpiredClaims is today — does not resolve this issue.
Test Coverage Requirements
packages/loopover-miner/lib/**/*.ts IS inside Codecov's coverage.include in vitest.config.ts, so the 99%+ branch-counted codecov/patch gate applies exactly as for src/**. Every new branch needs both arms: --max-age-ms supplied / omitted, valid / invalid, --dry-run on / off, --json on / off, and reclaimed-nonempty / reclaimed-empty.
Expected Outcome
An operator whose attempt process was hard-killed can clear the stranded soft-claim with one command instead of waiting out the 14-day expiry window, and reclaimExpiredClaims has the real production caller its own doc comment already claims it mirrors.
Links & Resources
packages/loopover-miner/lib/claim-ledger.ts:316-320, packages/loopover-miner/lib/claim-ledger-cli.ts:360-370, packages/loopover-miner/lib/portfolio-queue-cli.ts:396-430, packages/loopover-miner/lib/claim-ledger-expiry.ts, packages/loopover-miner/lib/cli.ts:43-45.
Context
ClaimLedger.reclaimExpiredClaims(maxAgeMs?)inpackages/loopover-miner/lib/claim-ledger.tsexists to expire claims orphaned by a crashed/killed process, and its doc comment names its intended shape:Its named model,
reclaimStuckItems, genuinely is wired: it runs automatically insideclaimNextBatch(portfolio-queue-manager.ts:174) and has an operator-facing manual escape hatch,loopover-miner queue release(portfolio-queue-cli.ts:398-430).reclaimExpiredClaimshas neither.grep -rn reclaimExpiredClaims packages/ test/ src/ apps/returns only its own declaration, its own implementation, andtest/unit/miner-claim-ledger-sweep-wiring.test.ts. TheclaimCLI dispatches exactly three subcommands —claim,release,list(claim-ledger-cli.ts:365-367) — with no reclaim/expire path, and neithercli.ts's help text nor the docs mention one.The operator-visible gap: the automatic sweep inside
claimIssue/claimIssueWithinCaphardcodesDEFAULT_MAX_CLAIM_AGE_MS= 14 days (claim-ledger-expiry.ts:5) and only ever runs when a new claim is attempted on some repo. A claim strandedactiveby aSIGKILLed attempt therefore counts against that repo'smaxConcurrentClaimscap (claim-ledger.ts:341-370) for up to two weeks, and there is no command an operator can run to clear it — while the exactly analogous portfolio-queue lease is both swept at 30 minutes and manually releasable.Requirements
reclaimsubcommand to the claim CLI:loopover-miner claim reclaim [--max-age-ms <n>] [--dry-run] [--json].ClaimLedger.reclaimExpiredClaims(maxAgeMs), passing the parsed--max-age-mswhen supplied and omitting it (so the ledger's ownDEFAULT_MAX_CLAIM_AGE_MSdefault applies) when not.--max-age-msmust accept only a finite integer at or above0; anything else returns the subcommand's usage string throughreportCliFailure.--dry-runmust print what would be reclaimed and return0without opening the claim ledger at all, matchingrunQueueRelease's dry-run shape (portfolio-queue-cli.ts:404-412).--jsonprints{ "reclaimed": [...] }(the returnedClaimEntry[]); plain text prints one line per reclaimed claim plus a count, andnonewhen nothing was reclaimed. Exit0in both cases — reclaiming nothing is not a failure.reclaiminrunClaimCli's dispatch alongsideclaim/release/list, and add the new usage line toprintHelpinpackages/loopover-miner/lib/cli.tsnext to the otherclaimlines.Deliverables
packages/loopover-miner/lib/claim-ledger-cli.tsexports aparseClaimReclaimArgsand arunClaimReclaim, andrunClaimClidispatchessubcommand === "reclaim"to it.printHelpinpackages/loopover-miner/lib/cli.tslistsloopover-miner claim reclaim [--max-age-ms <n>] [--dry-run] [--json].test/unit/miner-claim-ledger-cli.test.tscover: a reclaim that transitions an over-age active claim toexpired; a reclaim that returnsnonewhen nothing is over-age;--max-age-mshonoured (a claim inside the default window but outside an explicit smaller window is reclaimed); an invalid--max-age-msreturning the usage error; and--dry-runreturning0without opening the ledger (assert via an injected opener that is never called).runClaimCli("reclaim", ...)is reachable from the dispatcher.All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example adding
runClaimReclaimwithout registering it inrunClaimCli's dispatch and inprintHelp, leaving it as unreachable asreclaimExpiredClaimsis today — does not resolve this issue.Test Coverage Requirements
packages/loopover-miner/lib/**/*.tsIS inside Codecov'scoverage.includeinvitest.config.ts, so the 99%+ branch-countedcodecov/patchgate applies exactly as forsrc/**. Every new branch needs both arms:--max-age-mssupplied / omitted, valid / invalid,--dry-runon / off,--jsonon / off, and reclaimed-nonempty / reclaimed-empty.Expected Outcome
An operator whose attempt process was hard-killed can clear the stranded soft-claim with one command instead of waiting out the 14-day expiry window, and
reclaimExpiredClaimshas the real production caller its own doc comment already claims it mirrors.Links & Resources
packages/loopover-miner/lib/claim-ledger.ts:316-320,packages/loopover-miner/lib/claim-ledger-cli.ts:360-370,packages/loopover-miner/lib/portfolio-queue-cli.ts:396-430,packages/loopover-miner/lib/claim-ledger-expiry.ts,packages/loopover-miner/lib/cli.ts:43-45.