From 7173c8701d0a95e8e0f916df152f8fcffa933252 Mon Sep 17 00:00:00 2001 From: bitfathers94 <237535319+bitfathers94@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:58:34 +0000 Subject: [PATCH] miner(attempt): close the AttemptCliResult and RunAttemptOptions .d.ts drifts Add a blocked_max_concurrent_claims variant to the AttemptCliResult union carrying the reason/maxConcurrentClaims/activeClaimCount fields the cap-race blockedResult builds, and drop the as-cast plus drift comment on its onResult call. Declare loadReputationHistory on RunAttemptOptions beside the other resolver seams and remove the inline cast that read it off the options object. Runtime is unchanged: same emitted fields, same exit code 11, same options.loadReputationHistory ?? loadReputationHistory resolution order. --- packages/loopover-miner/lib/attempt-cli.ts | 19 +++-- test/unit/miner-attempt-cli.test.ts | 96 ++++++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) 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(