Skip to content

Commit 93e80ac

Browse files
kai392jak-glitchclaude
authored
fix(miner): drop discovery-index candidates whose repo bans AI contributions (#9680) (#9908)
`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. Tests: an 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; and a direct recordDiscoveryTelemetry case (both opt-ins on) covering the payload with the count present, so both arms of the conditional spread are covered. Closes #9680 Co-authored-by: kai392 <chengjunkai4@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cb167ba commit 93e80ac

4 files changed

Lines changed: 115 additions & 5 deletions

File tree

packages/loopover-miner/lib/discover-cli.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,19 @@ async function supplementWithDiscoveryIndex(
186186
if (!isDiscoveryPlaneEnabled(env)) return fanOut;
187187
const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex;
188188
const response = await queryIndex(queryScope, { env });
189-
recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env });
190-
if (response.candidates.length === 0) return fanOut;
189+
// #9680: the hosted index preserves a repo's AI-contribution ban (normalizeDiscoveryIndexCandidate keeps
190+
// aiPolicyAllowed:false), but nothing downstream re-checks it -- the `as RawCandidateIssue` cast below would
191+
// launder a `false` through a type declared as the literal `true`, so an AI-banned repo's issue would be ranked
192+
// and enqueued. Enforce the ban here exactly as the local fetchTargetIssues does (`if (!verdict.allowed) return
193+
// []`): drop it, not down-rank or warn. A candidate that omits the field is kept (`!== false`, matching
194+
// normalizeDiscoveryIndexCandidate's own default).
195+
const aiAllowed = response.candidates.filter((candidate) => candidate.aiPolicyAllowed !== false);
196+
const droppedAiBanned = response.candidates.length - aiAllowed.length;
197+
recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env, droppedAiBanned });
198+
if (aiAllowed.length === 0) return fanOut;
191199

192200
const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber)));
193-
const supplemented = response.candidates
201+
const supplemented = aiAllowed
194202
.filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber)))
195203
// DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; copy the real assignees through when the
196204
// hosted contract carried them (#7442), falling back to [] only when the served response genuinely omitted the

packages/loopover-miner/lib/discovery-index-client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,16 @@ export async function submitSoftClaim(
171171
export function recordDiscoveryTelemetry(
172172
event: string,
173173
outcome: string,
174-
options: { env?: Record<string, string | undefined> } = {},
174+
options: { env?: Record<string, string | undefined>; droppedAiBanned?: number } = {},
175175
): void {
176176
const env = options.env ?? process.env;
177177
if (!isDiscoveryPlaneEnabled(env) || !isDiscoveryTelemetryEnabled(env)) return;
178-
getLogger().info("discovery_plane_telemetry", { event, outcome });
178+
// #9680: low-cardinality count of index candidates dropped for an AI-contribution ban this query, when the
179+
// caller supplies it. Spread conditionally so callers that don't pass it keep the exact { event, outcome }
180+
// shape (and payload) they emitted before.
181+
getLogger().info("discovery_plane_telemetry", {
182+
event,
183+
outcome,
184+
...(options.droppedAiBanned !== undefined ? { droppedAiBanned: options.droppedAiBanned } : {}),
185+
});
179186
}

test/unit/miner-discover-cli.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,89 @@ describe("discovery-index supplementation (#7168)", () => {
20352035
]);
20362036
});
20372037

2038+
it("REGRESSION (#9680): drops an index candidate whose repo bans AI contributions, keeps the allowed one, and reports the dropped count", async () => {
2039+
const clientModule = await import("../../packages/loopover-miner/lib/discovery-index-client");
2040+
const telemetrySpy = vi.spyOn(clientModule, "recordDiscoveryTelemetry");
2041+
const portfolioQueue = tempQueueStore();
2042+
const fetchCandidateIssuesWithSummary = vi.fn(async () => ({
2043+
issues: [fanOutIssue({ issueNumber: 1, title: "Local candidate" })],
2044+
warnings: [],
2045+
rateLimitRemaining: 100,
2046+
rateLimitResetAt: null,
2047+
}));
2048+
const queryDiscoveryIndex = vi.fn(async () => ({
2049+
contractVersion: 1,
2050+
candidates: [
2051+
indexCandidate({ issueNumber: 2, title: "AI-banned repo issue", aiPolicyAllowed: false }),
2052+
indexCandidate({ issueNumber: 3, title: "Allowed index issue", aiPolicyAllowed: true }),
2053+
],
2054+
nextCursor: null,
2055+
}));
2056+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
2057+
2058+
const exitCode = await runDiscover(["acme/widgets", "--json"], {
2059+
nowMs: NOW,
2060+
env: { LOOPOVER_MINER_DISCOVERY_PLANE: "true" },
2061+
initPortfolioQueue: () => portfolioQueue,
2062+
initPolicyDocCache: () => tempPolicyDocCacheStore(),
2063+
initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(),
2064+
initRankedCandidatesStore: () => tempRankedCandidatesStore(),
2065+
fetchCandidateIssuesWithSummary,
2066+
queryDiscoveryIndex: queryDiscoveryIndex as never,
2067+
});
2068+
2069+
expect(exitCode).toBe(0);
2070+
const payload = JSON.parse(String(log.mock.calls[0]?.[0]));
2071+
const issueNumbers = payload.ranked
2072+
.map((entry: { issueNumber: number }) => entry.issueNumber)
2073+
.sort((a: number, b: number) => a - b);
2074+
// #2 (aiPolicyAllowed:false) is dropped before ranking; the local #1 and the allowed index #3 remain.
2075+
// Before the fix #2 would also be ranked and enqueued into the miner's backlog.
2076+
expect(issueNumbers).toEqual([1, 3]);
2077+
expect(telemetrySpy).toHaveBeenCalledWith(
2078+
"discover_query",
2079+
"supplemented",
2080+
expect.objectContaining({ droppedAiBanned: 1 }),
2081+
);
2082+
});
2083+
2084+
it("REGRESSION (#9680): keeps an index candidate that OMITS aiPolicyAllowed (field-absent is not a ban)", async () => {
2085+
const portfolioQueue = tempQueueStore();
2086+
const fetchCandidateIssuesWithSummary = vi.fn(async () => ({
2087+
issues: [fanOutIssue({ issueNumber: 1, title: "Local candidate" })],
2088+
warnings: [],
2089+
rateLimitRemaining: 100,
2090+
rateLimitResetAt: null,
2091+
}));
2092+
const candidateNoField = indexCandidate({ issueNumber: 4, title: "Older-build candidate" });
2093+
delete (candidateNoField as { aiPolicyAllowed?: unknown }).aiPolicyAllowed;
2094+
const queryDiscoveryIndex = vi.fn(async () => ({
2095+
contractVersion: 1,
2096+
candidates: [candidateNoField],
2097+
nextCursor: null,
2098+
}));
2099+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
2100+
2101+
const exitCode = await runDiscover(["acme/widgets", "--json"], {
2102+
nowMs: NOW,
2103+
env: { LOOPOVER_MINER_DISCOVERY_PLANE: "true" },
2104+
initPortfolioQueue: () => portfolioQueue,
2105+
initPolicyDocCache: () => tempPolicyDocCacheStore(),
2106+
initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(),
2107+
initRankedCandidatesStore: () => tempRankedCandidatesStore(),
2108+
fetchCandidateIssuesWithSummary,
2109+
queryDiscoveryIndex: queryDiscoveryIndex as never,
2110+
});
2111+
2112+
expect(exitCode).toBe(0);
2113+
const payload = JSON.parse(String(log.mock.calls[0]?.[0]));
2114+
const issueNumbers = payload.ranked
2115+
.map((entry: { issueNumber: number }) => entry.issueNumber)
2116+
.sort((a: number, b: number) => a - b);
2117+
// A candidate that never carried aiPolicyAllowed must NOT become a fail-closed drop.
2118+
expect(issueNumbers).toEqual([1, 4]);
2119+
});
2120+
20382121
it("uses the --search term as the discovery-index scope in search mode", async () => {
20392122
const portfolioQueue = tempQueueStore();
20402123
const searchCandidateIssuesWithSummary = vi.fn(async () => ({

test/unit/miner-discovery-index-client.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,18 @@ describe("recordDiscoveryTelemetry (#7168)", () => {
225225
});
226226
expect(logSpy.info).toHaveBeenCalledWith("discovery_plane_telemetry", { event: "discover_query", outcome: "supplemented" });
227227
});
228+
229+
it("#9680: carries the droppedAiBanned count in the payload when the caller supplies it", () => {
230+
recordDiscoveryTelemetry("discover_query", "supplemented", {
231+
env: { [DISCOVERY_PLANE_FLAG]: "true", [DISCOVERY_TELEMETRY_FLAG]: "true" },
232+
droppedAiBanned: 2,
233+
});
234+
expect(logSpy.info).toHaveBeenCalledWith("discovery_plane_telemetry", {
235+
event: "discover_query",
236+
outcome: "supplemented",
237+
droppedAiBanned: 2,
238+
});
239+
});
228240
});
229241

230242
describe("defaulted options (real process.env / real global fetch) (#7168)", () => {

0 commit comments

Comments
 (0)