diff --git a/packages/loopover-miner/lib/discover-cli.ts b/packages/loopover-miner/lib/discover-cli.ts index d6b6262f30..22fc990cf8 100644 --- a/packages/loopover-miner/lib/discover-cli.ts +++ b/packages/loopover-miner/lib/discover-cli.ts @@ -186,11 +186,19 @@ async function supplementWithDiscoveryIndex( if (!isDiscoveryPlaneEnabled(env)) return fanOut; const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; const response = await queryIndex(queryScope, { env }); - recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); - if (response.candidates.length === 0) return fanOut; + // #9680: the hosted index preserves a repo's AI-contribution ban (normalizeDiscoveryIndexCandidate keeps + // aiPolicyAllowed:false), but nothing downstream re-checks it -- the `as RawCandidateIssue` cast below would + // launder a `false` through a type declared as the literal `true`, so an AI-banned repo's issue would be ranked + // and enqueued. Enforce the ban here exactly as the local fetchTargetIssues does (`if (!verdict.allowed) return + // []`): drop it, not down-rank or warn. A candidate that omits the field is kept (`!== false`, matching + // normalizeDiscoveryIndexCandidate's own default). + const aiAllowed = response.candidates.filter((candidate) => candidate.aiPolicyAllowed !== false); + const droppedAiBanned = response.candidates.length - aiAllowed.length; + recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env, droppedAiBanned }); + if (aiAllowed.length === 0) return fanOut; const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); - const supplemented = response.candidates + const supplemented = aiAllowed .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; copy the real assignees through when the // hosted contract carried them (#7442), falling back to [] only when the served response genuinely omitted the diff --git a/packages/loopover-miner/lib/discovery-index-client.ts b/packages/loopover-miner/lib/discovery-index-client.ts index 294b7c9e50..dae0c1e215 100644 --- a/packages/loopover-miner/lib/discovery-index-client.ts +++ b/packages/loopover-miner/lib/discovery-index-client.ts @@ -171,9 +171,16 @@ export async function submitSoftClaim( export function recordDiscoveryTelemetry( event: string, outcome: string, - options: { env?: Record } = {}, + options: { env?: Record; droppedAiBanned?: number } = {}, ): void { const env = options.env ?? process.env; if (!isDiscoveryPlaneEnabled(env) || !isDiscoveryTelemetryEnabled(env)) return; - getLogger().info("discovery_plane_telemetry", { event, outcome }); + // #9680: low-cardinality count of index candidates dropped for an AI-contribution ban this query, when the + // caller supplies it. Spread conditionally so callers that don't pass it keep the exact { event, outcome } + // shape (and payload) they emitted before. + getLogger().info("discovery_plane_telemetry", { + event, + outcome, + ...(options.droppedAiBanned !== undefined ? { droppedAiBanned: options.droppedAiBanned } : {}), + }); } diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 0abc63348c..9a23e1deb5 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -2035,6 +2035,89 @@ describe("discovery-index supplementation (#7168)", () => { ]); }); + it("REGRESSION (#9680): drops an index candidate whose repo bans AI contributions, keeps the allowed one, and reports the dropped count", async () => { + const clientModule = await import("../../packages/loopover-miner/lib/discovery-index-client"); + const telemetrySpy = vi.spyOn(clientModule, "recordDiscoveryTelemetry"); + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1, title: "Local candidate" })], + warnings: [], + rateLimitRemaining: 100, + rateLimitResetAt: null, + })); + const queryDiscoveryIndex = vi.fn(async () => ({ + contractVersion: 1, + candidates: [ + indexCandidate({ issueNumber: 2, title: "AI-banned repo issue", aiPolicyAllowed: false }), + indexCandidate({ issueNumber: 3, title: "Allowed index issue", aiPolicyAllowed: true }), + ], + nextCursor: null, + })); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets", "--json"], { + nowMs: NOW, + env: { LOOPOVER_MINER_DISCOVERY_PLANE: "true" }, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + fetchCandidateIssuesWithSummary, + queryDiscoveryIndex: queryDiscoveryIndex as never, + }); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + const issueNumbers = payload.ranked + .map((entry: { issueNumber: number }) => entry.issueNumber) + .sort((a: number, b: number) => a - b); + // #2 (aiPolicyAllowed:false) is dropped before ranking; the local #1 and the allowed index #3 remain. + // Before the fix #2 would also be ranked and enqueued into the miner's backlog. + expect(issueNumbers).toEqual([1, 3]); + expect(telemetrySpy).toHaveBeenCalledWith( + "discover_query", + "supplemented", + expect.objectContaining({ droppedAiBanned: 1 }), + ); + }); + + it("REGRESSION (#9680): keeps an index candidate that OMITS aiPolicyAllowed (field-absent is not a ban)", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1, title: "Local candidate" })], + warnings: [], + rateLimitRemaining: 100, + rateLimitResetAt: null, + })); + const candidateNoField = indexCandidate({ issueNumber: 4, title: "Older-build candidate" }); + delete (candidateNoField as { aiPolicyAllowed?: unknown }).aiPolicyAllowed; + const queryDiscoveryIndex = vi.fn(async () => ({ + contractVersion: 1, + candidates: [candidateNoField], + nextCursor: null, + })); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets", "--json"], { + nowMs: NOW, + env: { LOOPOVER_MINER_DISCOVERY_PLANE: "true" }, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + fetchCandidateIssuesWithSummary, + queryDiscoveryIndex: queryDiscoveryIndex as never, + }); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + const issueNumbers = payload.ranked + .map((entry: { issueNumber: number }) => entry.issueNumber) + .sort((a: number, b: number) => a - b); + // A candidate that never carried aiPolicyAllowed must NOT become a fail-closed drop. + expect(issueNumbers).toEqual([1, 4]); + }); + it("uses the --search term as the discovery-index scope in search mode", async () => { const portfolioQueue = tempQueueStore(); const searchCandidateIssuesWithSummary = vi.fn(async () => ({ diff --git a/test/unit/miner-discovery-index-client.test.ts b/test/unit/miner-discovery-index-client.test.ts index 730a515e28..d71cc67e76 100644 --- a/test/unit/miner-discovery-index-client.test.ts +++ b/test/unit/miner-discovery-index-client.test.ts @@ -225,6 +225,18 @@ describe("recordDiscoveryTelemetry (#7168)", () => { }); expect(logSpy.info).toHaveBeenCalledWith("discovery_plane_telemetry", { event: "discover_query", outcome: "supplemented" }); }); + + it("#9680: carries the droppedAiBanned count in the payload when the caller supplies it", () => { + recordDiscoveryTelemetry("discover_query", "supplemented", { + env: { [DISCOVERY_PLANE_FLAG]: "true", [DISCOVERY_TELEMETRY_FLAG]: "true" }, + droppedAiBanned: 2, + }); + expect(logSpy.info).toHaveBeenCalledWith("discovery_plane_telemetry", { + event: "discover_query", + outcome: "supplemented", + droppedAiBanned: 2, + }); + }); }); describe("defaulted options (real process.env / real global fetch) (#7168)", () => {