From 9195b2076e467fde61e3f91c170e6f9e5b8aebd0 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:53:02 -0700 Subject: [PATCH 1/3] feat(miner): resolve rejectionSignaled's AI-usage-policy-ban trigger IterateLoopInput.rejectionSignaled has two documented triggers per iterate-policy.ts's own doc comment: an explicit AI-usage-policy ban on the target repo, or a prior submission from this miner closed/rejected on that exact repo. Adds resolveRejectionSignaled(repoFullName, options), which fetches AI-USAGE.md/CONTRIBUTING.md live from raw.githubusercontent.com (falling through to CONTRIBUTING.md only when AI-USAGE.md is absent/empty) and runs them through the engine's existing resolveAiPolicyVerdict -- the same check opportunity-fanout.js already runs during discovery, applied here at attempt time. The second trigger (a prior submission from this same miner was closed/ rejected on this exact repo) is deliberately not resolved here -- left as a documented follow-up. Fixes the new test's fetch stubs to satisfy SelfReviewContextFetch's response shape (json()/text(): Promise, not Promise). --- .../lib/rejection-signal.d.ts | 6 + .../gittensory-miner/lib/rejection-signal.js | 69 ++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-rejection-signal.test.ts | 103 ++++++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/rejection-signal.d.ts create mode 100644 packages/gittensory-miner/lib/rejection-signal.js create mode 100644 test/unit/miner-rejection-signal.test.ts 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-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; + } + }); +}); From 9c68db8b61a32e5c25d9e44b9865e11f2df8d5a2 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:53:02 -0700 Subject: [PATCH 2/3] feat(miner): resolve rejectionSignaled's AI-usage-policy-ban trigger Advances #5132 IterateLoopInput.rejectionSignaled has two documented triggers: an explicit AI-usage-policy ban on the target repo, or a prior submission from this miner closed/rejected on that exact repo. This adds resolveRejectionSignaled, which fetches AI-USAGE.md/CONTRIBUTING.md live and runs them through the engine's existing resolveAiPolicyVerdict -- the same check opportunity-fanout.js already runs during discovery, applied here at attempt time. The second trigger (own prior-rejection history) needs governor-state's listRecentOwnSubmissions cross-checked against live PR outcomes via rejection-state-machine.js's resolveRejection -- a separately-scoped pipeline, left as a follow-up rather than fabricated as "no rejection history." --- test/unit/miner-rejection-signal.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/unit/miner-rejection-signal.test.ts b/test/unit/miner-rejection-signal.test.ts index f604a5b525..bf380fde19 100644 --- a/test/unit/miner-rejection-signal.test.ts +++ b/test/unit/miner-rejection-signal.test.ts @@ -6,21 +6,16 @@ vi.mock("@jsonbored/gittensory-engine", async () => { 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 ?? "", + text: async () => text, }; } /** Routes by URL substring; a null respond() throws to simulate a network failure. */ -function routedFetch(routes: Record ReturnType>) { +function routedFetch(routes: Record unknown>) { return async (url: string) => { for (const [substring, respond] of Object.entries(routes)) { if (url.includes(substring)) return respond(); From 8237ede6e8e1924a517c94d4b13cbb24c7c112ab Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:22:20 -0700 Subject: [PATCH 3/3] feat(miner): wire resolveRejectionSignaled into the real attempt path Advances #5132 Review feedback on this branch correctly flagged resolveRejectionSignaled as unwired: nothing called it yet. Wires it into attempt-cli.js's runAttempt, checked before acquiring a worktree slot so a repo that already bans AI contributions never consumes one. Adds the "blocked_rejection_signaled" outcome (exit code 5), logged to both the attempt log and event ledger like the existing blocked path. --- .../gittensory-miner/lib/attempt-cli.d.ts | 4 + packages/gittensory-miner/lib/attempt-cli.js | 50 ++++++++++++- test/unit/miner-attempt-cli.test.ts | 75 +++++++++++++++++++ test/unit/miner-rejection-signal.test.ts | 9 ++- 4 files changed, 132 insertions(+), 6 deletions(-) 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/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 index bf380fde19..f604a5b525 100644 --- a/test/unit/miner-rejection-signal.test.ts +++ b/test/unit/miner-rejection-signal.test.ts @@ -6,16 +6,21 @@ vi.mock("@jsonbored/gittensory-engine", async () => { 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, - text: async () => text, + 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 unknown>) { +function routedFetch(routes: Record ReturnType>) { return async (url: string) => { for (const [substring, respond] of Object.entries(routes)) { if (url.includes(substring)) return respond();