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
20 changes: 11 additions & 9 deletions packages/loopover-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { initEventLedger } from "./event-ledger.js";
import { initAttemptLog } from "./attempt-log.js";
import { initGovernorLedger } from "./governor-ledger.js";
import { openWorktreeAllocator } from "./worktree-allocator.js";
import { resolveRejectionSignaled } from "./rejection-signal.js";
import { REJECTION_REASON_AI_USAGE_POLICY_BAN, REJECTION_REASON_OWN_SUBMISSION_REJECTED, resolveRejectionSignaled } from "./rejection-signal.js";
import { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js";
import { fetchSelfReviewContext } from "./self-review-context.js";
import { buildCodingTaskSpec } from "./coding-task-spec.js";
Expand Down Expand Up @@ -212,14 +212,14 @@ export async function runAttempt(args, options = {}) {
attemptLog = (options.initAttemptLog ?? initAttemptLog)();
governorLedger = (options.initGovernorLedger ?? initGovernorLedger)();

// Checked before acquiring a worktree slot: a banned repo should never consume one. This resolves the
// first of rejectionSignaled's two documented triggers (an explicit AI-usage-policy ban, #5132 follow-up)
// -- the second (a prior own-submission rejection on this exact repo) remains a documented gap, see
// rejection-signal.js's own header for why.
// Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one.
// resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection
// history) and returns a trigger-specific reason string for accurate audit-trail labeling.
const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled;
const rejectionSignaled = await resolveRejection(parsed.repoFullName, { fetchImpl: options.fetchImpl });
if (rejectionSignaled) {
const reason = "ai_usage_policy_ban";
const rejectionSignal = await resolveRejection(parsed.repoFullName, { fetchImpl: options.fetchImpl });
if (rejectionSignal) {
const reason =
rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal;
attemptLog.appendAttemptLogEvent({
eventType: "attempt_aborted",
attemptId,
Expand Down Expand Up @@ -247,7 +247,9 @@ export async function runAttempt(args, options = {}) {
console.log(JSON.stringify(rejectedResult, null, 2));
} else {
console.error(
`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`,
reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED
? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.`
: `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`,
);
}
options.onResult?.(rejectedResult);
Expand Down
7 changes: 6 additions & 1 deletion packages/loopover-miner/lib/rejection-signal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ export interface RejectionSignaledOptions extends OwnRejectionHistoryOptions {
rawContentBaseUrl?: string;
}

export type RejectionSignaledReason = "ai_usage_policy_ban" | "own_submission_rejected";

export const REJECTION_REASON_AI_USAGE_POLICY_BAN: "ai_usage_policy_ban";
export const REJECTION_REASON_OWN_SUBMISSION_REJECTED: "own_submission_rejected";

export function resolveRejectionSignaled(
repoFullName: string,
options?: RejectionSignaledOptions,
): Promise<boolean>;
): Promise<false | RejectionSignaledReason | true>;

export function resolveOwnRejectionHistory(
repoFullName: string,
Expand Down
24 changes: 16 additions & 8 deletions packages/loopover-miner/lib/rejection-signal.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ import { resolveRejection } from "./rejection-state-machine.js";
// resolved by resolveOwnRejectionHistory (#5655), closing the gap this header previously documented: it checks
// each of this miner's recorded own-submissions on the repo (governor-state.js's listRecentOwnSubmissions,
// #5134) against its live PR outcome via rejection-state-machine.js's resolveRejection (#4278) -- consuming both
// upstream modules without modifying either. resolveRejectionSignaled now returns true if EITHER trigger fires,
// so `rejectionSignaled` finally means what iterate-policy.ts's doc comment has always said.
// upstream modules without modifying either. resolveRejectionSignaled now returns a trigger-specific reason
// string if EITHER trigger fires (or `false` when neither does), so `rejectionSignaled` finally means what
// iterate-policy.ts's doc comment has always said.

/** @typedef {"ai_usage_policy_ban" | "own_submission_rejected"} RejectionSignaledReason */

export const REJECTION_REASON_AI_USAGE_POLICY_BAN = "ai_usage_policy_ban";
export const REJECTION_REASON_OWN_SUBMISSION_REJECTED = "own_submission_rejected";

const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com";
const MAX_POLICY_DOC_BYTES = 128 * 1024;
Expand Down Expand Up @@ -149,13 +155,14 @@ export async function resolveOwnRejectionHistory(repoFullName, options = {}) {
}

/**
* Resolve whether the target repo has an explicit, live AI-usage-policy ban -- the first of
* `rejectionSignaled`'s two documented triggers. Returns `false` (never throws) on any fetch/parse failure,
* matching resolveAiPolicyVerdict's own fail-open default for an absent/unreadable policy doc.
* Resolve whether the target repo has signaled it does not want automated/AI-authored contributions --
* either trigger documented above. Returns `false` (never throws) on any fetch/parse failure for the policy
* docs, matching resolveAiPolicyVerdict's own fail-open default for an absent/unreadable policy doc. When a
* trigger fires, returns a trigger-specific reason string so callers can label audit-trail events accurately.
*
* @param {string} repoFullName
* @param {{ rawContentBaseUrl?: string, fetchImpl?: import("./self-review-context.js").SelfReviewContextFetch }} [options]
* @returns {Promise<boolean>}
* @returns {Promise<false | RejectionSignaledReason>}
*/
export async function resolveRejectionSignaled(repoFullName, options = {}) {
const target = parseRepoFullName(repoFullName);
Expand All @@ -167,7 +174,8 @@ export async function resolveRejectionSignaled(repoFullName, options = {}) {

const verdict = resolveAiPolicyVerdict({ aiUsage, contributing });
// First trigger: an explicit live AI-usage-policy ban. A ban short-circuits -- no need to also check history.
if (!verdict.allowed) return true;
if (!verdict.allowed) return REJECTION_REASON_AI_USAGE_POLICY_BAN;
// Second trigger (#5655): a prior submission from this same miner on this exact repo was closed/rejected.
return resolveOwnRejectionHistory(repoFullName, options);
const ownHistoryRejected = await resolveOwnRejectionHistory(repoFullName, options);
return ownHistoryRejected ? REJECTION_REASON_OWN_SUBMISSION_REJECTED : false;
}
86 changes: 77 additions & 9 deletions test/unit/miner-attempt-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { closeDefaultGovernorState } from "../../packages/loopover-miner/lib/gov
import { buildAttemptDeps, parseAttemptArgs, runAttempt } from "../../packages/loopover-miner/lib/attempt-cli.js";
import * as minerSentryModule from "../../packages/loopover-miner/lib/sentry.js";
import type { PrepareAttemptWorktreeResult } from "../../packages/loopover-miner/lib/attempt-worktree.js";
import {
REJECTION_REASON_AI_USAGE_POLICY_BAN,
REJECTION_REASON_OWN_SUBMISSION_REJECTED,
type RejectionSignaledReason,
} from "../../packages/loopover-miner/lib/rejection-signal.js";
import { DEFAULT_AMS_POLICY_SPEC, DEFAULT_MINER_GOAL_SPEC, parseFocusManifest } from "../../packages/loopover-engine/src/index";

const roots: string[] = [];
Expand Down Expand Up @@ -73,7 +78,7 @@ function fakeLoopResult(overrides: Record<string, unknown> = {}) {
* through) the final runMinerAttempt call, without doing any real network/git/subprocess work. */
function readyPipelineOptions(overrides: Record<string, unknown> = {}) {
return {
resolveRejectionSignaled: async () => false,
resolveRejectionSignaled: async (): Promise<false | RejectionSignaledReason> => false,
prepareAttemptWorktree: async () => fakeWorktreeResult(),
cleanupAttemptWorktree: vi.fn().mockResolvedValue({ ok: true, removed: true }),
fetchSelfReviewContext: async () => fakeReviewContext(),
Expand Down Expand Up @@ -1041,7 +1046,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
resolveRejectionSignaled: async (): Promise<false | RejectionSignaledReason> => false,
});

expect(exitCode).toBe(3);
Expand Down Expand Up @@ -1074,7 +1079,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
resolveRejectionSignaled: async (): Promise<false | RejectionSignaledReason> => false,
});

expect(exitCode).toBe(2);
Expand All @@ -1088,7 +1093,7 @@ describe("runAttempt (#5132)", () => {
const acquireSpy = vi.spyOn(allocator, "acquire");
const appendAttemptLogEventSpy = vi.spyOn(attemptLog, "appendAttemptLogEvent");
const appendEventSpy = vi.spyOn(eventLedger, "appendEvent");
const resolveRejectionSignaledSpy = vi.fn().mockResolvedValue(true);
const resolveRejectionSignaledSpy = vi.fn().mockResolvedValue(REJECTION_REASON_AI_USAGE_POLICY_BAN);

const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], {
env: { MINER_CODING_AGENT_PROVIDER: "noop" },
Expand Down Expand Up @@ -1123,13 +1128,76 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => true,
resolveRejectionSignaled: async () => REJECTION_REASON_AI_USAGE_POLICY_BAN,
});

expect(exitCode).toBe(5);
expect(error).toHaveBeenCalledWith(expect.stringContaining("AI-usage policy bans automated/AI-authored contributions"));
});

it("REGRESSION (#6055): labels own-rejection-history aborts as own_submission_rejected in --json output", async () => {
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const appendAttemptLogEventSpy = vi.spyOn(attemptLog, "appendAttemptLogEvent");

const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], {
env: { MINER_CODING_AGENT_PROVIDER: "noop" },
openWorktreeAllocator: () => allocator,
openClaimLedger: () => claimLedger,
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => REJECTION_REASON_OWN_SUBMISSION_REJECTED,
});

expect(exitCode).toBe(5);
expect(appendAttemptLogEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ eventType: "attempt_aborted", reason: REJECTION_REASON_OWN_SUBMISSION_REJECTED }),
);
const payload = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
expect(payload).toMatchObject({
outcome: "blocked_rejection_signaled",
reason: REJECTION_REASON_OWN_SUBMISSION_REJECTED,
});
});

it("REGRESSION (#6055): maps legacy boolean true from resolveRejectionSignaled to ai_usage_policy_ban", async () => {
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], {
env: { MINER_CODING_AGENT_PROVIDER: "noop" },
openWorktreeAllocator: () => allocator,
openClaimLedger: () => claimLedger,
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async (): Promise<true> => true,
});

expect(exitCode).toBe(5);
const payload = JSON.parse(String(log.mock.calls.at(-1)?.[0]));
expect(payload.reason).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
});

it("REGRESSION (#6055): reports a human-readable message for own-rejection-history aborts", async () => {
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);

const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], {
env: { MINER_CODING_AGENT_PROVIDER: "noop" },
openWorktreeAllocator: () => allocator,
openClaimLedger: () => claimLedger,
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => REJECTION_REASON_OWN_SUBMISSION_REJECTED,
});

expect(exitCode).toBe(5);
expect(error).toHaveBeenCalledWith(expect.stringContaining("this miner was previously rejected on this repo"));
});

it("passes options.fetchImpl through to resolveRejectionSignaled", async () => {
const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers();
vi.spyOn(console, "error").mockImplementation(() => undefined);
Expand Down Expand Up @@ -1166,7 +1234,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
resolveRejectionSignaled: async (): Promise<false | RejectionSignaledReason> => false,
prepareAttemptWorktree: async () => ({ ok: false, error: "git_clone_failed" }),
cleanupAttemptWorktree: cleanupAttemptWorktreeSpy,
});
Expand Down Expand Up @@ -1202,7 +1270,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
resolveRejectionSignaled: async (): Promise<false | RejectionSignaledReason> => false,
prepareAttemptWorktree: async () => ({ ok: false, error: "git_fetch_failed" }),
cleanupAttemptWorktree: vi.fn(),
});
Expand Down Expand Up @@ -1265,7 +1333,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => rejectedLedgers.eventLedger,
initAttemptLog: () => rejectedLedgers.attemptLog,
initGovernorLedger: () => rejectedLedgers.governorLedger,
resolveRejectionSignaled: async () => true,
resolveRejectionSignaled: async () => REJECTION_REASON_AI_USAGE_POLICY_BAN,
onResult,
});
expect(rejectedExit).toBe(5);
Expand Down Expand Up @@ -1467,7 +1535,7 @@ describe("runAttempt: real claim-ledger wiring (#5393)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => true,
resolveRejectionSignaled: async () => REJECTION_REASON_AI_USAGE_POLICY_BAN,
});

expect(claimIssueSpy).not.toHaveBeenCalled();
Expand Down
20 changes: 11 additions & 9 deletions test/unit/miner-rejection-signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ vi.mock("@loopover/engine", async () => {
});

import {
REJECTION_REASON_AI_USAGE_POLICY_BAN,
REJECTION_REASON_OWN_SUBMISSION_REJECTED,
resolveOwnRejectionHistory,
resolveRejectionSignaled,
} from "../../packages/loopover-miner/lib/rejection-signal.js";
Expand Down Expand Up @@ -39,7 +41,7 @@ describe("resolveRejectionSignaled (#5132)", () => {
"CONTRIBUTING.md": () => textResponse("Welcome, contributors!"),
});
const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl });
expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
});

it("returns false when neither policy doc bans AI contributions", async () => {
Expand All @@ -57,7 +59,7 @@ describe("resolveRejectionSignaled (#5132)", () => {
"CONTRIBUTING.md": () => textResponse("Do not submit AI-generated code."),
});
const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl });
expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
});

it("does not fetch CONTRIBUTING.md when a non-empty AI-USAGE.md decides the policy", async () => {
Expand All @@ -68,7 +70,7 @@ describe("resolveRejectionSignaled (#5132)", () => {

const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl });

expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls[0]?.[0]).toContain("AI-USAGE.md");
});
Expand Down Expand Up @@ -110,7 +112,7 @@ describe("resolveRejectionSignaled (#5132)", () => {

const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl });

expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
});

it("treats an oversized non-streamed policy document as absent", async () => {
Expand All @@ -131,7 +133,7 @@ describe("resolveRejectionSignaled (#5132)", () => {
const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl });

// AI-USAGE.md is treated as absent (oversized), so the verdict falls through to CONTRIBUTING.md's ban.
expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
});

it("cancels a streamed policy document once it exceeds the byte limit", async () => {
Expand Down Expand Up @@ -195,7 +197,7 @@ describe("resolveRejectionSignaled (#5132)", () => {

const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl });

expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
});

it("fails open to false when both docs 404", async () => {
Expand Down Expand Up @@ -382,11 +384,11 @@ describe("resolveRejectionSignaled combines both triggers (#5655)", () => {
}),
listSubmissions,
});
expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_AI_USAGE_POLICY_BAN);
expect(listSubmissions).not.toHaveBeenCalled();
});

it("returns true from the own-rejection-history trigger when the policy docs are clean", async () => {
it("returns own_submission_rejected from the own-rejection-history trigger when the policy docs are clean", async () => {
const policyFetch = routedFetch({
"AI-USAGE.md": () => textResponse("AI contributions are welcome here."),
"CONTRIBUTING.md": () => textResponse("Welcome, contributors!"),
Expand All @@ -398,7 +400,7 @@ describe("resolveRejectionSignaled combines both triggers (#5655)", () => {
fetchImpl,
listSubmissions: () => [{ pullRequestNumber: 42 }],
});
expect(result).toBe(true);
expect(result).toBe(REJECTION_REASON_OWN_SUBMISSION_REJECTED);
});

it("returns false when neither trigger fires (clean policy + no prior rejection)", async () => {
Expand Down