diff --git a/packages/gittensory-miner/lib/attempt-cli.js b/packages/gittensory-miner/lib/attempt-cli.js index f2aad1f806..4b8f110fc6 100644 --- a/packages/gittensory-miner/lib/attempt-cli.js +++ b/packages/gittensory-miner/lib/attempt-cli.js @@ -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"; @@ -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 = @@ -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 diff --git a/packages/gittensory-miner/lib/attempt-input-builder.d.ts b/packages/gittensory-miner/lib/attempt-input-builder.d.ts index 770b8fdf4f..c8b64d892e 100644 --- a/packages/gittensory-miner/lib/attempt-input-builder.d.ts +++ b/packages/gittensory-miner/lib/attempt-input-builder.d.ts @@ -3,6 +3,7 @@ import type { CodingAgentExecutionMode, IterateLoopInput, PortfolioConvergenceInput, + RepoOutcomeHistory, SelfReviewContext, } from "@loopover/engine"; import type { AttemptGovernorContext } from "./attempt-runner.js"; @@ -13,6 +14,7 @@ export function buildAttemptGovernorContext( amsPolicySpec: AmsPolicySpec, repoPaused?: boolean, convergenceInput?: PortfolioConvergenceInput, + reputationHistory?: RepoOutcomeHistory, ): AttemptGovernorContext; export type BuildAttemptLoopInputInput = { diff --git a/packages/gittensory-miner/lib/attempt-input-builder.js b/packages/gittensory-miner/lib/attempt-input-builder.js index aeadebc6dd..ea93419f10 100644 --- a/packages/gittensory-miner/lib/attempt-input-builder.js +++ b/packages/gittensory-miner/lib/attempt-input-builder.js @@ -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/ @@ -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} 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 }), }; } diff --git a/packages/gittensory-miner/lib/loop-cli.js b/packages/gittensory-miner/lib/loop-cli.js index 54e56d89ea..c1edb03db5 100644 --- a/packages/gittensory-miner/lib/loop-cli.js +++ b/packages/gittensory-miner/lib/loop-cli.js @@ -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"; @@ -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); } } diff --git a/test/unit/miner-attempt-input-builder.test.ts b/test/unit/miner-attempt-input-builder.test.ts index 430c469cdf..07efe9df04 100644 --- a/test/unit/miner-attempt-input-builder.test.ts +++ b/test/unit/miner-attempt-input-builder.test.ts @@ -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 = {}) { return { @@ -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"); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts index 252b80fa97..b4fd5b868d 100644 --- a/test/unit/miner-loop-cli.test.ts +++ b/test/unit/miner-loop-cli.test.ts @@ -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) => { + (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);