diff --git a/packages/gittensory-miner/lib/attempt-runner.js b/packages/gittensory-miner/lib/attempt-runner.js index c31786d598..e57ee82ade 100644 --- a/packages/gittensory-miner/lib/attempt-runner.js +++ b/packages/gittensory-miner/lib/attempt-runner.js @@ -1,4 +1,5 @@ import { buildOpenPrSpec } from "@loopover/engine"; +import { fingerprintFromChangedFiles } from "@loopover/engine"; import { runIterateLoop } from "@loopover/engine"; import { checkSubmissionFreshness } from "./submission-freshness-check.js"; import { evaluateGovernorChokepointGatePersisted } from "./governor-chokepoint-persisted.js"; @@ -31,10 +32,11 @@ import { prepareOpenPrSubmission } from "./harness-submission-trigger.js"; // evaluateGovernorChokepointGatePersisted -- callers no longer need to hand-thread honest empty/zero defaults // on every invocation; `capUsage` is loaded from that same store but its post-attempt save stays the caller's // job (see governor-chokepoint-persisted.js's own header for why: nothing computes "the next capUsage" from a -// verdict, only the attempt's real outcome does). Reputation/self-plagiarism state also has real persistence -// primitives (governor-state.js) but isn't auto-loaded here yet -- `input.governor.reputationHistory`/ -// `selfPlagiarismCandidate`/`selfPlagiarismRecentSubmissions` are still caller-supplied optional fields on -// GovernorChokepointInput, same as before. +// verdict, only the attempt's real outcome does). `selfPlagiarismCandidate`/`selfPlagiarismRecentSubmissions` are +// now computed HERE (#5676), at the open_pr chokepoint call, because the prospective submission's real +// changed-files fingerprint only exists once the loop reaches handoff -- too late for the caller's single early +// governor snapshot; the miner's recent-submission history comes from governor-state.js's listRecentOwnSubmissions. +// `input.governor.reputationHistory` remains the caller's early per-repo read (#5675). /** True once the loop reaches handoff AND every downstream gate (freshness, submission, governor) allows. */ export const ATTEMPT_OUTCOMES = Object.freeze(["abandon", "stale", "blocked", "governed", "submitted"]); @@ -141,6 +143,26 @@ export async function runMinerAttempt(input, deps) { return { outcome: "blocked", decision: submission.decision, loopResult }; } + // Real self-plagiarism inputs (#5676): the prospective submission's own changed-files fingerprint -- computed the + // same way attempt-cli.js's recordOwnSubmission does (fingerprintFromChangedFiles over the handoff packet's + // changed files) -- plus the miner's real recent-submission history, so the chokepoint's self-plagiarism throttle + // catches a near-duplicate re-submission. Needs a governorState to read history from and a non-empty fingerprint; + // without either the fields stay absent -- an honest skip of that stage, never a fabricated clean history. + const candidateFingerprint = fingerprintFromChangedFiles(handoffPacket.changedFiles?.map((file) => file.path) ?? []); + const selfPlagiarism = + deps.governorState && candidateFingerprint + ? { + selfPlagiarismCandidate: { + repoFullName: input.loopInput.repoFullName, + fingerprint: candidateFingerprint, + submittedAt: new Date(deps.nowMs).toISOString(), + }, + selfPlagiarismRecentSubmissions: deps.governorState.listRecentOwnSubmissions({ + repoFullName: input.loopInput.repoFullName, + }), + } + : {}; + const governed = evaluateGovernorChokepointGatePersisted( { actionClass: "open_pr", @@ -148,6 +170,7 @@ export async function runMinerAttempt(input, deps) { nowMs: deps.nowMs, wouldBeAction: submission.openPrInput, ...input.governor, + ...selfPlagiarism, }, { ...(deps.governorLedgerAppend ? { append: deps.governorLedgerAppend } : {}), diff --git a/test/unit/miner-attempt-runner.test.ts b/test/unit/miner-attempt-runner.test.ts index 7ceadb8e3d..9f03b0d824 100644 --- a/test/unit/miner-attempt-runner.test.ts +++ b/test/unit/miner-attempt-runner.test.ts @@ -11,7 +11,7 @@ import { runMinerAttempt } from "../../packages/gittensory-miner/lib/attempt-run import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; import { openGovernorState } from "../../packages/gittensory-miner/lib/governor-state.js"; -import { parseFocusManifest, type CodingAgentDriver, type CodingAgentDriverResult } from "../../packages/gittensory-engine/src/index"; +import { fingerprintFromChangedFiles, parseFocusManifest, type CodingAgentDriver, type CodingAgentDriverResult } from "../../packages/gittensory-engine/src/index"; const roots: string[] = []; const closers: Array<{ close(): void }> = []; @@ -267,6 +267,39 @@ describe("runMinerAttempt (#2337) — the real create->review->gate->submit pipe expect(result.decision.stage).toBe("kill_switch"); }); + it("REGRESSION (#5676): a near-duplicate of a real recent own submission is throttled at the self-plagiarism stage", async () => { + const deps = baseDeps(); + // The driver's changed files (okDriverResult -> ["src/upload.ts"]) fingerprint the prospective submission the + // same way attempt-runner computes it; a prior own submission with that SAME fingerprint is the near-duplicate + // the chokepoint must catch now that the candidate + recent-history reach it (#5676). + deps.governorState.recordOwnSubmission({ + repoFullName: "acme/widgets", + fingerprint: fingerprintFromChangedFiles(["src/upload.ts"]), + // Earlier than the prospective submission (its submittedAt is new Date(deps.nowMs)), so THIS attempt is the + // later near-duplicate the self-plagiarism throttle denies -- matching self-plagiarism.ts's "the later of two + // near-identical submissions is the plagiarist" tie-break. + submittedAt: "1970-01-01T00:00:00.000Z", + pullRequestNumber: 1, + }); + const result = await runMinerAttempt(baseAttemptInput(), deps); + expect(result.outcome).toBe("governed"); + if (result.outcome !== "governed") throw new Error("expected governed"); + expect(result.decision.allowed).toBe(false); + expect(result.decision.stage).toBe("self_plagiarism"); + }); + + it("REGRESSION (#5676): a submission genuinely distinct from every recent own submission is NOT throttled", async () => { + const deps = baseDeps(); + deps.governorState.recordOwnSubmission({ + repoFullName: "acme/widgets", + fingerprint: fingerprintFromChangedFiles(["docs/README.md"]), + submittedAt: "2026-07-10T00:00:00Z", + pullRequestNumber: 1, + }); + const result = await runMinerAttempt(baseAttemptInput(), deps); + expect(result.outcome).toBe("submitted"); + }); + it("falls back to the real default governor-ledger append when governorLedgerAppend is omitted", async () => { const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-runner-default-governor-")); roots.push(root);