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
3 changes: 3 additions & 0 deletions packages/loopover-engine/src/portfolio/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
10 changes: 6 additions & 4 deletions packages/loopover-miner/lib/portfolio-queue-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): 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. */
Expand Down
12 changes: 12 additions & 0 deletions test/unit/miner-portfolio-queue-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
14 changes: 13 additions & 1 deletion test/unit/portfolio-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down