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
19 changes: 12 additions & 7 deletions packages/loopover-miner/lib/attempt-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down
96 changes: 96 additions & 0 deletions test/unit/miner-attempt-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down