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
14 changes: 9 additions & 5 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
// checkSubmissionFreshness cannot see (two miners submitting almost simultaneously).
//
// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list):
// governor.reputationHistory/selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's
// own design treats that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue
// portfolio-queue.js read (#5654), not a placeholder.
// governor.selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's own design treats
// that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue portfolio-queue.js read
// (#5654) and governor.reputationHistory a real per-repo governor-state.js read (#5675), not placeholders.

import { fingerprintFromChangedFiles, resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine";
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
Expand All @@ -35,7 +35,7 @@ import { resolveAmsPolicy } from "./ams-policy.js";
import { checkMinerKillSwitch } from "./governor-kill-switch.js";
import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-input-builder.js";
import { getAttemptHistory } from "./portfolio-queue.js";
import { recordOwnSubmission } from "./governor-state.js";
import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js";
import { runMinerAttempt } from "./attempt-runner.js";

const ATTEMPT_USAGE =
Expand Down Expand Up @@ -409,7 +409,11 @@ export async function runAttempt(args, options = {}) {
// pre-#5563 single-forge caller already reads) the github.com default.
const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory;
const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`);
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput);
// Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo,
// read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing.
const readReputationHistory = options.loadReputationHistory ?? loadReputationHistory;
const reputationHistory = readReputationHistory(parsed.repoFullName);
const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory);

// Real soft-claim (#5393): recorded once we've committed to a real attempt (past feasibility), so a
// sibling miner process on this machine sees it via claimLedger.listClaims/listActiveClaims while this
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/attempt-input-builder.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CodingAgentExecutionMode,
IterateLoopInput,
PortfolioConvergenceInput,
RepoOutcomeHistory,
SelfReviewContext,
} from "@loopover/engine";
import type { AttemptGovernorContext } from "./attempt-runner.js";
Expand All @@ -13,6 +14,7 @@ export function buildAttemptGovernorContext(
amsPolicySpec: AmsPolicySpec,
repoPaused?: boolean,
convergenceInput?: PortfolioConvergenceInput,
reputationHistory?: RepoOutcomeHistory,
): AttemptGovernorContext;

export type BuildAttemptLoopInputInput = {
Expand Down
23 changes: 15 additions & 8 deletions packages/gittensory-miner/lib/attempt-input-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@loopover/e
// same discipline as coding-task-spec.js's own composers.
//
// KNOWN, DOCUMENTED GAPS (not fabricated -- explicitly left as real, narrow follow-ups):
// - governor.reputationHistory/selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted, which
// chokepoint.ts's own design treats as "skip that stage entirely" -- an honest absence, not a fabricated
// "clean" verdict.
// - governor.selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted, which chokepoint.ts's own
// design treats as "skip that stage entirely" -- an honest absence, not a fabricated "clean" verdict.
//
// governor.convergenceInput is now a REAL per-issue attempt-history query (#5654): the caller (attempt-cli.js)
// reads it from portfolio-queue.js's own getAttemptHistory and passes it in here, this composer staying pure
// over it same as every other already-computed dependency below. The zero-state fallback only fires when a
// caller genuinely omits the argument -- an honest first-attempt shape, not the old hardcoded literal.
// governor.convergenceInput is now a REAL per-issue attempt-history query (#5654) and governor.reputationHistory
// a REAL per-repo governor-state query (#5675): the caller (attempt-cli.js) reads them from portfolio-queue.js's
// getAttemptHistory and governor-state.js's loadReputationHistory and passes them in here, this composer staying
// pure over them same as every other already-computed dependency below. An omitted argument stays an honest
// absence (zero-state convergence / skipped reputation throttle), never a fabricated clean history.

/**
* Assemble the real Governor chokepoint context for one attempt. rateLimitBuckets/rateLimitBackoffAttempts/
Expand All @@ -29,19 +29,26 @@ import { isGlobalMinerKillSwitch, isGlobalMinerLiveModeOptIn } from "@loopover/e
* back to the honest first-attempt-shaped zero-state, so a caller that hasn't wired a real read yet (or an
* item genuinely absent from the queue) still produces a well-formed `PortfolioConvergenceInput`.
*
* `reputationHistory` (#5675) is the caller's own real governor-state.js `loadReputationHistory` read for the
* target repo. Optional and threaded through unchanged: when omitted the field is left off entirely, which
* chokepoint.ts treats as "skip the self-reputation throttle" -- an honest absence, never a fabricated clean
* history.
*
* @param {Record<string, string | undefined>} env
* @param {import("@loopover/engine").AmsPolicySpec} amsPolicySpec
* @param {boolean} [repoPaused]
* @param {import("@loopover/engine").PortfolioConvergenceInput} [convergenceInput]
* @param {import("@loopover/engine").RepoOutcomeHistory} [reputationHistory]
* @returns {import("./attempt-runner.js").AttemptGovernorContext}
*/
export function buildAttemptGovernorContext(env, amsPolicySpec, repoPaused, convergenceInput) {
export function buildAttemptGovernorContext(env, amsPolicySpec, repoPaused, convergenceInput, reputationHistory) {
return {
killSwitchGlobal: isGlobalMinerKillSwitch(env),
killSwitchRepoPaused: repoPaused,
liveModeGlobalOptIn: isGlobalMinerLiveModeOptIn(env),
capLimits: amsPolicySpec.capLimits,
convergenceInput: convergenceInput ?? { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false },
...(reputationHistory === undefined ? {} : { reputationHistory }),
};
}

Expand Down
15 changes: 15 additions & 0 deletions packages/gittensory-miner/lib/loop-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { resolveAmsPolicy } from "./ams-policy.js";
import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js";
import { pollCheckRuns } from "./ci-poller.js";
import { recordPrOutcomeSnapshot } from "./pr-outcome.js";
import { isRejectedPr } from "./rejection-state-machine.js";
import { buildLoopClosureSummary } from "./loop-closure.js";
import { attemptLoopReentry } from "./loop-reentry.js";
import { parsePrNumberFromExecResult } from "./pr-number-parse.js";
Expand Down Expand Up @@ -459,6 +460,20 @@ export async function runLoop(args, options = {}) {
},
{ eventLedger },
);
// Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable
// counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always;
// `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching
// #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other
// governor-state write here.
const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl);
governorState.saveReputationHistory(
claimed.repoFullName,
{
decided: priorReputation.decided + 1,
unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0),
},
claimed.apiBaseUrl,
);
reentryOutcome = classifyPrDisposition(prDisposition);
}
}
Expand Down
34 changes: 33 additions & 1 deletion test/unit/miner-attempt-input-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ vi.mock("@loopover/engine", async () => {
});

import { buildAttemptGovernorContext, buildAttemptLoopInput } from "../../packages/gittensory-miner/lib/attempt-input-builder.js";
import { DEFAULT_AMS_POLICY_SPEC, parseFocusManifest } from "../../packages/gittensory-engine/src/index";
import { DEFAULT_AMS_POLICY_SPEC, evaluateGovernorChokepoint, parseFocusManifest } from "../../packages/gittensory-engine/src/index";

function codingTaskSpec(overrides: Record<string, unknown> = {}) {
return {
Expand Down Expand Up @@ -69,6 +69,38 @@ describe("buildAttemptGovernorContext (#5132)", () => {
expect(ctx.convergenceInput).toEqual(realHistory);
});

it("REGRESSION (#5675): a real reputationHistory the caller passes threads through unchanged", () => {
const ctx = buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC, false, undefined, { decided: 8, unfavorable: 5 });
expect(ctx.reputationHistory).toEqual({ decided: 8, unfavorable: 5 });
});

it("omits reputationHistory entirely when the caller passes none, so chokepoint.ts skips the throttle (honest absence)", () => {
expect(buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC)).not.toHaveProperty("reputationHistory");
});

it("REGRESSION (#5675): a repo's real unfavorable-outcome streak, threaded through the governor context, throttles the chokepoint", () => {
const ctx = buildAttemptGovernorContext(
{ GITTENSORY_MINER_LIVE_MODE: "live" },
DEFAULT_AMS_POLICY_SPEC,
false,
undefined,
{ decided: 10, unfavorable: 8 },
);
const decision = evaluateGovernorChokepoint({
actionClass: "open_pr",
repoFullName: "acme/widgets",
nowMs: 10_000,
wouldBeAction: { action: "open_pr", title: "Fix bug" },
liveModeRepoOptIn: "live",
rateLimitBuckets: { global: {}, perRepo: {} },
rateLimitBackoffAttempts: {},
capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 },
...ctx,
});
expect(decision.allowed).toBe(false);
expect(decision.stage).toBe("reputation_throttle");
});

it("omits rateLimitBuckets/rateLimitBackoffAttempts/capUsage so the persisted governor-state store auto-supplies them", () => {
const ctx = buildAttemptGovernorContext({}, DEFAULT_AMS_POLICY_SPEC);
expect(ctx).not.toHaveProperty("rateLimitBuckets");
Expand Down
42 changes: 42 additions & 0 deletions test/unit/miner-loop-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,48 @@ describe("runLoop (#5135)", () => {
expect(after.portfolioQueue.listQueue()[0]).toMatchObject({ status: "queued" });
});

it("REGRESSION (#5675): a resolved closed-without-merge outcome records an unfavorable reputation-history entry for the repo", async () => {
const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores();
vi.spyOn(console, "log").mockImplementation(() => undefined);
const item = { repoFullName: "acme/widgets", identifier: "issue:7" };
const runDiscoverSpy = primeOnceDiscover(portfolioQueue, item);
const runAttemptSpy = vi.fn(async (_args: string[], options?: Record<string, unknown>) => {
(options?.onResult as ((result: unknown) => void) | undefined)?.({
outcome: "attempt_submitted",
repoFullName: "acme/widgets",
issueNumber: 7,
minerLogin: "alice",
base: "main",
mode: "dry_run",
attemptId: "loop-attempt-rep",
execResult: { action: "open_pr", stdout: "https://github.com/acme/widgets/pull/9\n", stderr: "", code: 0, timedOut: false },
});
return 0;
});
const pollPrDispositionSpy = vi.fn().mockResolvedValue({ state: "closed", merged: false, closedAt: "2026-07-12T00:00:00Z", attempts: 1 });
const pollCheckRunsSpy = vi.fn().mockResolvedValue({ conclusion: "failure", checks: [], headSha: "abc", attempts: 1 });

const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "1", "--json"], {
env: { GITHUB_TOKEN: "ghp_loop_test" },
openGovernorState: () => governorState,
initEventLedger: () => eventLedger,
initGovernorLedger: () => governorLedger,
initPortfolioQueue: () => portfolioQueue,
initRunStateStore: () => runState,
runDiscover: runDiscoverSpy,
runAttempt: runAttemptSpy,
pollPrDisposition: pollPrDispositionSpy,
pollCheckRuns: pollCheckRunsSpy,
...readyLoopOptions(),
});

expect(exitCode).toBe(0);
// A closed-without-merge terminal outcome increments BOTH `decided` and `unfavorable` (isRejectedPr), so the
// Governor's self-reputation throttle reads a real degraded track record on this repo's next attempt.
const after = reopenAfterRun(paths);
expect(after.governorState.loadReputationHistory("acme/widgets")).toEqual({ decided: 1, unfavorable: 1 });
});

it("REGRESSION: runs a full cycle end to end -- claims, attempts, polls real PR disposition, records the outcome, and re-enters", async () => {
const { eventLedger, governorLedger, portfolioQueue, runState, governorState, paths } = tempStores();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
Expand Down