diff --git a/packages/loopover-miner/lib/attempt-cli.ts b/packages/loopover-miner/lib/attempt-cli.ts index 00e90641e6..2c83968a3b 100644 --- a/packages/loopover-miner/lib/attempt-cli.ts +++ b/packages/loopover-miner/lib/attempt-cli.ts @@ -99,6 +99,12 @@ export type AttemptCliResult = | (CommonAttemptResultFields & { outcome: "dry_run" }) | (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string }) | (CommonAttemptResultFields & { outcome: "blocked_own_open_pr"; reason: string; existingPullRequestNumber: number }) + | (CommonAttemptResultFields & { + outcome: "blocked_max_concurrent_claims"; + reason: string; + maxConcurrentClaims: number; + activeClaimCount: number; + }) | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) | (CommonAttemptResultFields & { outcome: "blocked_infeasible"; @@ -172,6 +178,9 @@ export type RunAttemptOptions = { resolveClaimConflict?: typeof ResolveClaimConflictFn; recordOwnSubmission?: typeof RecordOwnSubmissionFn; getAttemptHistory?: typeof GetAttemptHistoryFn; + /** Per-repo reputation-history loader for the self-reputation throttle (#5675). Defaults to governor-state.js's + * own loadReputationHistory. */ + loadReputationHistory?: typeof loadReputationHistory; /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to * discovery-index-client.js's own submitSoftClaim. */ submitSoftClaim?: typeof SubmitSoftClaimFn; @@ -771,10 +780,7 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); // 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. - // loadReputationHistory is used at runtime but omitted from RunAttemptOptions (.d.ts drift). - const readReputationHistory = - (options as RunAttemptOptions & { loadReputationHistory?: typeof loadReputationHistory }).loadReputationHistory ?? - loadReputationHistory; + const readReputationHistory = options.loadReputationHistory ?? loadReputationHistory; const reputationHistory = readReputationHistory(parsed.repoFullName); const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); @@ -815,7 +821,7 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} payload: { issueNumber: parsed.issueNumber, reason }, }); const blockedResult = { - outcome: "blocked_max_concurrent_claims", + outcome: "blocked_max_concurrent_claims" as const, reason, maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, activeClaimCount: claimResult.activeClaimCount, @@ -837,8 +843,7 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} ].join("\n"), ); } - // blocked_max_concurrent_claims is a real runtime outcome omitted from AttemptCliResult (.d.ts drift). - options.onResult?.(blockedResult as AttemptCliResult); + options.onResult?.(blockedResult); return 11; } diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index 923d9d2224..ce4c9e8031 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -166,6 +166,102 @@ describe("AttemptCliResult union (#9331)", () => { }); }); +describe("AttemptCliResult / RunAttemptOptions .d.ts drifts (#9685)", () => { + it("includes the blocked_max_concurrent_claims outcome with the runtime blockedResult shape", () => { + // Compile-time guard: this is the exact object runAttempt builds when the cap is met (#6758). Before the + // union member was added it only type-checked behind an `as AttemptCliResult` cast; removing the member + // makes this assignment a type error, so the .d.ts drift can't silently return. + const result: AttemptCliResult = { + outcome: "blocked_max_concurrent_claims", + reason: "max_concurrent_claims_exceeded", + maxConcurrentClaims: 2, + activeClaimCount: 3, + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "miner", + base: "main", + mode: "live", + attemptId: "attempt-1", + }; + expect(result.outcome).toBe("blocked_max_concurrent_claims"); + if (result.outcome === "blocked_max_concurrent_claims") { + expect(result.maxConcurrentClaims).toBe(2); + expect(result.activeClaimCount).toBe(3); + } + }); + + it("reports blocked_max_concurrent_claims (exit 11) through onResult when the claim ledger denies the claim", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + // Injected claim ledger response: the atomic count-and-claim loses the cap race (#6758). + vi.spyOn(claimLedger, "claimIssueWithinCap").mockReturnValue({ + claimed: false, + claim: null, + activeClaimCount: 3, + maxConcurrentClaims: 2, + }); + const onResult = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "capped-attempt", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ + resolveMinerGoalSpec: () => ({ present: true, spec: { ...DEFAULT_MINER_GOAL_SPEC, maxConcurrentClaims: 2 }, warnings: [] }), + }), + onResult, + }); + + expect(exitCode).toBe(11); + expect(onResult).toHaveBeenCalledTimes(1); + const [reported] = onResult.mock.calls[0]!; + expect(reported).toMatchObject({ + outcome: "blocked_max_concurrent_claims", + reason: "max_concurrent_claims_exceeded", + maxConcurrentClaims: 2, + activeClaimCount: 3, + repoFullName: "acme/widgets", + issueNumber: 7, + }); + }); + + it("uses an injected loadReputationHistory passed through the plain (uncast) RunAttemptOptions", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(claimLedger, "claimIssueWithinCap").mockReturnValue({ + claimed: false, + claim: null, + activeClaimCount: 1, + maxConcurrentClaims: 1, + }); + const loadReputationHistory = vi.fn().mockReturnValue({ decided: 0, unfavorable: 0 }); + // Typed as the plain RunAttemptOptions with NO cast: before loadReputationHistory was declared on the type + // this direct property would be an excess-property error. No onResult here, so the `?.` short-circuits. + const options: RunAttemptOptions = { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "reputation-seam-attempt", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + loadReputationHistory, + ...readyPipelineOptions({ + resolveMinerGoalSpec: () => ({ present: true, spec: { ...DEFAULT_MINER_GOAL_SPEC, maxConcurrentClaims: 1 }, warnings: [] }), + }), + }; + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], options); + + expect(exitCode).toBe(11); + expect(loadReputationHistory).toHaveBeenCalledWith("acme/widgets"); + }); +}); + describe("parseAttemptArgs (#5132)", () => { it("parses a full, valid argv", () => { expect(