From 4939b33fc2a5b051a7e4ad4471a759160371d0f3 Mon Sep 17 00:00:00 2001 From: kai392 Date: Thu, 30 Jul 2026 05:33:44 +0800 Subject: [PATCH] fix(miner): drop discovery-index candidates whose repo bans AI contributions (#9680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supplementWithDiscoveryIndex` folded hosted-index candidates into the local fan-out with dedupe as the only filter. The index client deliberately preserves a repo's AI-contribution ban (`aiPolicyAllowed !== false`), but nothing downstream re-checks it -- the `as RawCandidateIssue` cast (the type declares `aiPolicyAllowed: true`) laundered a `false` straight through, so an AI-banned repo's issue was ranked and enqueued into the miner's own portfolio backlog, where the loop would later claim and attempt it. The local path enforces the ban hard (`fetchTargetIssues`: `if (!verdict.allowed) return []`); the index path did not. - discover-cli.ts: filter out `aiPolicyAllowed === false` candidates before the dedupe/cast, using `!== false` so a candidate omitting the field is still kept (matching normalizeDiscoveryIndexCandidate's own default). - discovery-index-client.ts: `recordDiscoveryTelemetry` now carries an optional `droppedAiBanned` count, spread conditionally so existing callers keep their exact `{ event, outcome }` payload; the `discover_query` call reports the number dropped. Two named cases: one index candidate `aiPolicyAllowed:false` + one `true` → only the allowed one is ranked and `droppedAiBanned:1` is reported (fails against current code); a candidate omitting the field is still kept. Closes #9680 Co-Authored-By: Claude Opus 4.8 --- packages/loopover-miner/lib/discover-cli.ts | 14 +++- .../lib/discovery-index-client.ts | 11 ++- test/unit/miner-discover-cli.test.ts | 83 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) 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 () => ({