diff --git a/packages/loopover-engine/src/portfolio/queue.ts b/packages/loopover-engine/src/portfolio/queue.ts index 50fa1fef7c..bd77ad67cd 100644 --- a/packages/loopover-engine/src/portfolio/queue.ts +++ b/packages/loopover-engine/src/portfolio/queue.ts @@ -57,6 +57,9 @@ function normalizeItem(item: PortfolioQueueItem): PortfolioQueueItem { } function finiteNonNegativeInt(value: number): number { + // Infinity means "uncapped" (this codebase's own convention -- portfolio-queue-cli.ts documents it), NOT + // "collapse to 0", which would make nextEligibleItems return [] for globalWipCap: Infinity (#8861). + if (value === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY; if (!Number.isFinite(value)) return 0; return Math.max(0, Math.trunc(value)); } diff --git a/packages/loopover-miner/lib/portfolio-queue-manager.ts b/packages/loopover-miner/lib/portfolio-queue-manager.ts index a71e76917b..086c08c930 100644 --- a/packages/loopover-miner/lib/portfolio-queue-manager.ts +++ b/packages/loopover-miner/lib/portfolio-queue-manager.ts @@ -48,11 +48,13 @@ export function parseQueueItemId(id: unknown): PortfolioQueueClaimTarget { return { apiBaseUrl, repoFullName, identifier }; } -/** Coerce caps to finite non-negative integers (mirrors the engine's normalizeCaps posture). */ +/** Coerce caps to finite non-negative integers (mirrors the engine's normalizeCaps posture), preserving an + * explicit `Infinity` as "uncapped" -- the codebase convention portfolio-queue-cli.ts documents -- rather than + * collapsing it to 0 (which the engine would read as a full-exclusion cap; #8861). */ export function normalizePortfolioCaps(caps: Partial = {}): PortfolioCaps { - const globalWipCap = Number.isFinite(caps.globalWipCap) ? Math.max(0, Math.trunc(caps.globalWipCap as number)) : 0; - const perRepoWipCap = Number.isFinite(caps.perRepoWipCap) ? Math.max(0, Math.trunc(caps.perRepoWipCap as number)) : 0; - return { globalWipCap, perRepoWipCap }; + const normalize = (value: number | undefined): number => + value === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Number.isFinite(value) ? Math.max(0, Math.trunc(value as number)) : 0; + return { globalWipCap: normalize(caps.globalWipCap), perRepoWipCap: normalize(caps.perRepoWipCap) }; } /** Project persisted queue rows into the engine's in-memory PortfolioQueue (done rows omitted). Pure. */ diff --git a/test/unit/miner-portfolio-queue-manager.test.ts b/test/unit/miner-portfolio-queue-manager.test.ts index 2ecb880e50..1e7f200742 100644 --- a/test/unit/miner-portfolio-queue-manager.test.ts +++ b/test/unit/miner-portfolio-queue-manager.test.ts @@ -30,6 +30,18 @@ describe("normalizePortfolioCaps() (#4285)", () => { }); expect(normalizePortfolioCaps()).toEqual({ globalWipCap: 0, perRepoWipCap: 0 }); }); + + it("preserves an explicit Infinity cap as uncapped, not collapsed to 0 (#8861)", () => { + // The engine's nextEligibleItems reads globalWipCap: 0 as a full-exclusion cap; collapsing Infinity to 0 + // here would silently exclude everything. Infinity must survive as "uncapped" (the engine-side behavior is + // pinned in test/unit/portfolio-queue.test.ts). + expect(normalizePortfolioCaps({ globalWipCap: Number.POSITIVE_INFINITY, perRepoWipCap: 3 })).toEqual({ + globalWipCap: Number.POSITIVE_INFINITY, + perRepoWipCap: 3, + }); + // A finite/absent cap is still coerced exactly as before. + expect(normalizePortfolioCaps({ globalWipCap: 4.9, perRepoWipCap: -2 })).toEqual({ globalWipCap: 4, perRepoWipCap: 0 }); + }); }); describe("entriesToPortfolioQueue() / selectEligibleBatch() (#4285)", () => { diff --git a/test/unit/portfolio-queue.test.ts b/test/unit/portfolio-queue.test.ts index 353d796f78..88572d5217 100644 --- a/test/unit/portfolio-queue.test.ts +++ b/test/unit/portfolio-queue.test.ts @@ -104,10 +104,22 @@ describe("portfolio queue primitives", () => { it("returns no eligible items when either cap normalizes to zero", () => { const queue = queueOf(item("a-queued-1", "acme/alpha")); - expect(nextEligibleItems(queue, { globalWipCap: Number.POSITIVE_INFINITY, perRepoWipCap: 1 })).toEqual([]); + expect(nextEligibleItems(queue, { globalWipCap: 0, perRepoWipCap: 1 })).toEqual([]); expect(nextEligibleItems(queue, { globalWipCap: 2, perRepoWipCap: -1 })).toEqual([]); }); + it("treats an Infinity cap as uncapped, returning eligible items instead of an empty batch (#8861)", () => { + const queue = queueOf(item("a-queued-1", "acme/alpha"), item("b-queued-1", "acme/beta")); + + // Before the fix, Infinity collapsed to 0 and excluded EVERY item -- the opposite of the "uncapped" + // convention portfolio-queue-cli.ts documents. + expect( + nextEligibleItems(queue, { globalWipCap: Number.POSITIVE_INFINITY, perRepoWipCap: Number.POSITIVE_INFINITY }) + .map((entry) => entry.id) + .sort(), + ).toEqual(["a-queued-1", "b-queued-1"]); + }); + it("truncates fractional caps and treats NaN as zero", () => { const queue = queueOf( item("a-queued-1", "acme/alpha"),