diff --git a/packages/gittensory-miner/lib/attempt-cli.d.ts b/packages/gittensory-miner/lib/attempt-cli.d.ts index 5baf2e3005..fdf90d5879 100644 --- a/packages/gittensory-miner/lib/attempt-cli.d.ts +++ b/packages/gittensory-miner/lib/attempt-cli.d.ts @@ -5,6 +5,8 @@ import type { EventLedger } from "./event-ledger.js"; import type { AttemptLog } from "./attempt-log.js"; import type { GovernorLedger } from "./governor-ledger.js"; import type { WorktreeAllocator } from "./worktree-allocator.js"; +import type { resolveRejectionSignaled } from "./rejection-signal.js"; +import type { SelfReviewContextFetch } from "./self-review-context.js"; export type ParsedAttemptArgs = | { error: string } @@ -28,6 +30,8 @@ export type RunAttemptOptions = { initAttemptLog?: () => AttemptLog; initGovernorLedger?: () => GovernorLedger; buildAttemptDeps?: typeof buildAttemptDeps; + resolveRejectionSignaled?: typeof resolveRejectionSignaled; + fetchImpl?: SelfReviewContextFetch; }; export function runAttempt(args: string[], options?: RunAttemptOptions): Promise; diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js index a4b116a38e..04e997f49b 100644 --- a/packages/gittensory-miner/lib/attempt-cli.js +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -22,6 +22,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"; const ATTEMPT_USAGE = "Usage: gittensory-miner attempt --miner-login [--base ] [--live] [--json]"; @@ -118,10 +119,10 @@ export function buildAttemptDeps(env, ledgers) { } /** - * Run the `attempt` CLI subcommand. Acquires a real worktree slot (worktree-allocator.js's first - * production caller), assembles real AttemptDeps, then -- since no SelfReviewContext fetcher or - * coding-task-spec builder exists yet -- reports the block instead of calling runMinerAttempt with - * fabricated data. See this file's header for why. + * Run the `attempt` CLI subcommand. Checks resolveRejectionSignaled first (before consuming a worktree + * slot), then acquires a real worktree slot (worktree-allocator.js's first production caller), assembles + * real AttemptDeps, then -- since no SelfReviewContext fetcher or coding-task-spec builder exists yet -- + * reports the block instead of calling runMinerAttempt with fabricated data. See this file's header for why. */ export async function runAttempt(args, options = {}) { const parsed = parseAttemptArgs(args); @@ -158,6 +159,47 @@ 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. + const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; + const rejectionSignaled = await resolveRejection(parsed.repoFullName, { fetchImpl: options.fetchImpl }); + if (rejectionSignaled) { + const reason = "ai_usage_policy_ban"; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const rejectedResult = { + outcome: "blocked_rejection_signaled", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + 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.`, + ); + } + return 5; + } + allocation = allocator.acquire(attemptId, parsed.repoFullName); try { diff --git a/packages/gittensory-miner/lib/rejection-signal.d.ts b/packages/gittensory-miner/lib/rejection-signal.d.ts new file mode 100644 index 0000000000..8548b955ec --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-signal.d.ts @@ -0,0 +1,6 @@ +import type { SelfReviewContextFetch } from "./self-review-context.js"; + +export function resolveRejectionSignaled( + repoFullName: string, + options?: { rawContentBaseUrl?: string; fetchImpl?: SelfReviewContextFetch }, +): Promise; diff --git a/packages/gittensory-miner/lib/rejection-signal.js b/packages/gittensory-miner/lib/rejection-signal.js new file mode 100644 index 0000000000..2dfa4a24bb --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-signal.js @@ -0,0 +1,69 @@ +import { resolveAiPolicyVerdict } from "@jsonbored/gittensory-engine"; + +// Real rejectionSignaled resolver (#5132, Wave 3.5 follow-up). iterate-policy.ts's own doc comment: "True +// when the target repo (or this contributor's history with it) has signaled it does not want automated/ +// AI-authored contributions -- an explicit AI-usage-policy ban, or a prior submission from this same miner +// was closed/rejected on this exact repo. The caller resolves this ... and passes it in; this policy does +// not compute it itself." This module resolves the FIRST trigger: a real AI-USAGE.md/CONTRIBUTING.md ban, +// fetched live and scanned via the engine's own resolveAiPolicyVerdict -- the same check +// opportunity-fanout.js already runs during discovery, applied here at attempt time instead. +// +// The SECOND trigger (a prior submission from this same miner was closed/rejected on this exact repo) is +// DELIBERATELY not resolved here: it would need each of this miner's recorded own-submissions +// (governor-state.js's listRecentOwnSubmissions, #5134) checked against its live PR outcome via +// rejection-state-machine.js's resolveRejection -- a second, separately-scoped fetch-and-classify pipeline. +// Not fabricated as "no rejection history" -- explicitly left as a known, documented gap for a follow-up, +// same discipline as SelfReviewContext's bounties/issueQuality (#5145) and this file's own callers should +// not assume a false result here means "no rejection signal of any kind." + +const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; + +function parseRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +function normalizeOptions(options = {}) { + return { + rawContentBaseUrl: + typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, + fetchImpl: options.fetchImpl ?? fetch, + }; +} + +async function fetchPolicyDoc(target, path, resolved) { + const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; + try { + const response = await resolved.fetchImpl(url, { method: "GET", headers: { accept: "application/json", "user-agent": "gittensory-miner" } }); + if (!response.ok) return null; + const text = await response.text(); + return typeof text === "string" ? text : null; + } catch { + return null; + } +} + +/** + * 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. + * + * @param {string} repoFullName + * @param {{ rawContentBaseUrl?: string, fetchImpl?: import("./self-review-context.js").SelfReviewContextFetch }} [options] + * @returns {Promise} + */ +export async function resolveRejectionSignaled(repoFullName, options = {}) { + const target = parseRepoFullName(repoFullName); + if (!target) return false; + const resolved = normalizeOptions(options); + + const [aiUsage, contributing] = await Promise.all([ + fetchPolicyDoc(target, "AI-USAGE.md", resolved), + fetchPolicyDoc(target, "CONTRIBUTING.md", resolved), + ]); + + const verdict = resolveAiPolicyVerdict({ aiUsage, contributing }); + return !verdict.allowed; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index db576aa86f..9750cb55f3 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index bd2a33f18b..a6436dba5d 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -205,6 +205,7 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, + resolveRejectionSignaled: async () => false, }); expect(exitCode).toBe(4); @@ -240,6 +241,7 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, + resolveRejectionSignaled: async () => false, }); expect(exitCode).toBe(4); @@ -257,6 +259,7 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, + resolveRejectionSignaled: async () => false, }); expect(exitCode).toBe(4); @@ -278,6 +281,7 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, + resolveRejectionSignaled: async () => false, }); expect(exitCode).toBe(3); @@ -310,10 +314,81 @@ describe("runAttempt (#5132)", () => { initEventLedger: () => eventLedger, initAttemptLog: () => attemptLog, initGovernorLedger: () => governorLedger, + resolveRejectionSignaled: async () => false, }); expect(exitCode).toBe(2); expect(error).toHaveBeenCalledWith(expect.stringContaining("no_free_worktree_slots")); expect(closeSpy).toHaveBeenCalled(); }); + + it("blocks on a rejection-signaled repo before ever acquiring a worktree slot, without fabricating a run", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + 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 exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "rejected-attempt", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveRejectionSignaled: resolveRejectionSignaledSpy, + }); + + expect(exitCode).toBe(5); + expect(resolveRejectionSignaledSpy).toHaveBeenCalledWith("acme/widgets", expect.objectContaining({ fetchImpl: undefined })); + // No worktree slot was ever acquired for a repo we already know rejects AI contributions. + expect(acquireSpy).not.toHaveBeenCalled(); + expect(appendAttemptLogEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ eventType: "attempt_aborted", attemptId: "rejected-attempt", reason: "ai_usage_policy_ban" }), + ); + expect(appendEventSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "attempt_blocked", repoFullName: "acme/widgets" })); + expect(error).not.toHaveBeenCalled(); + }); + + it("blocks on a rejection-signaled repo with a human-readable message by default", 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 () => true, + }); + + expect(exitCode).toBe(5); + expect(error).toHaveBeenCalledWith(expect.stringContaining("AI-usage policy bans automated/AI-authored contributions")); + }); + + it("passes options.fetchImpl through to resolveRejectionSignaled", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const resolveRejectionSignaledSpy = vi.fn().mockResolvedValue(false); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const fetchImpl = vi.fn(); + + 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: resolveRejectionSignaledSpy, + fetchImpl, + }); + + expect(resolveRejectionSignaledSpy).toHaveBeenCalledWith("acme/widgets", { fetchImpl }); + expect(log).toHaveBeenCalled(); + }); }); diff --git a/test/unit/miner-rejection-signal.test.ts b/test/unit/miner-rejection-signal.test.ts new file mode 100644 index 0000000000..f604a5b525 --- /dev/null +++ b/test/unit/miner-rejection-signal.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { resolveRejectionSignaled } from "../../packages/gittensory-miner/lib/rejection-signal.js"; + +// resolveRejectionSignaled fetches plain markdown text (AI-USAGE.md/CONTRIBUTING.md), never JSON, so +// json() is never actually called -- it's here only to satisfy SelfReviewContextFetch's response shape. +function textResponse(text: string | null, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error("textResponse: json() is unused by resolveRejectionSignaled"); + }, + text: async () => text ?? "", + }; +} + +/** Routes by URL substring; a null respond() throws to simulate a network failure. */ +function routedFetch(routes: Record ReturnType>) { + return async (url: string) => { + for (const [substring, respond] of Object.entries(routes)) { + if (url.includes(substring)) return respond(); + } + return textResponse(null, 404); + }; +} + +describe("resolveRejectionSignaled (#5132)", () => { + it("returns true when AI-USAGE.md contains an explicit ban phrase", async () => { + const fetchImpl = routedFetch({ + "AI-USAGE.md": () => textResponse("No AI-generated pull requests, please."), + "CONTRIBUTING.md": () => textResponse("Welcome, contributors!"), + }); + const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl }); + expect(result).toBe(true); + }); + + it("returns false when neither policy doc bans AI contributions", async () => { + const fetchImpl = routedFetch({ + "AI-USAGE.md": () => textResponse("AI contributions are welcome here."), + "CONTRIBUTING.md": () => textResponse("Welcome, contributors!"), + }); + const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl }); + expect(result).toBe(false); + }); + + it("falls through to CONTRIBUTING.md's ban when AI-USAGE.md is empty", async () => { + const fetchImpl = routedFetch({ + "AI-USAGE.md": () => textResponse(""), + "CONTRIBUTING.md": () => textResponse("Do not submit AI-generated code."), + }); + const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl }); + expect(result).toBe(true); + }); + + it("fails open to false when both docs 404", async () => { + const fetchImpl = routedFetch({}); + const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl }); + expect(result).toBe(false); + }); + + it("fails open to false when a fetch throws (network error)", async () => { + const fetchImpl = async () => { + throw new Error("network unreachable"); + }; + const result = await resolveRejectionSignaled("acme/widgets", { fetchImpl }); + expect(result).toBe(false); + }); + + it("returns false for a malformed repoFullName, without calling fetch", async () => { + const fetchImpl = vi.fn(); + const result = await resolveRejectionSignaled("not-a-repo", { fetchImpl }); + expect(result).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("uses a custom rawContentBaseUrl when provided", async () => { + const calledUrls: string[] = []; + const fetchImpl = async (url: string) => { + calledUrls.push(url); + return textResponse(null, 404); + }; + await resolveRejectionSignaled("acme/widgets", { fetchImpl, rawContentBaseUrl: "https://raw.example.internal" }); + expect(calledUrls.every((url) => url.startsWith("https://raw.example.internal/acme/widgets/HEAD/"))).toBe(true); + }); + + it("defaults to the real global fetch when fetchImpl is omitted", async () => { + const originalFetch = globalThis.fetch; + const fetchSpy = vi.fn(async () => textResponse(null, 404)); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + try { + const result = await resolveRejectionSignaled("acme/widgets"); + expect(result).toBe(false); + expect(fetchSpy).toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); +});