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
4 changes: 4 additions & 0 deletions packages/gittensory-miner/lib/attempt-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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<number>;
50 changes: 46 additions & 4 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--json]";

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions packages/gittensory-miner/lib/rejection-signal.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { SelfReviewContextFetch } from "./self-review-context.js";

export function resolveRejectionSignaled(
repoFullName: string,
options?: { rawContentBaseUrl?: string; fetchImpl?: SelfReviewContextFetch },
): Promise<boolean>;
69 changes: 69 additions & 0 deletions packages/gittensory-miner/lib/rejection-signal.js
Original file line number Diff line number Diff line change
@@ -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://github.com/ghraw";

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<boolean>}
*/
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;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*"
Expand Down
75 changes: 75 additions & 0 deletions test/unit/miner-attempt-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
});

expect(exitCode).toBe(4);
Expand Down Expand Up @@ -240,6 +241,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
});

expect(exitCode).toBe(4);
Expand All @@ -257,6 +259,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
});

expect(exitCode).toBe(4);
Expand All @@ -278,6 +281,7 @@ describe("runAttempt (#5132)", () => {
initEventLedger: () => eventLedger,
initAttemptLog: () => attemptLog,
initGovernorLedger: () => governorLedger,
resolveRejectionSignaled: async () => false,
});

expect(exitCode).toBe(3);
Expand Down Expand Up @@ -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();
});
});
Loading