Skip to content

Commit f2fd057

Browse files
authored
fix(autonomy): retry generic merge 403s before holding (#2861)
1 parent 3ad1f6b commit f2fd057

4 files changed

Lines changed: 54 additions & 17 deletions

File tree

src/services/agent-action-executor.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ function shouldRefreshInstallationHealthAfterPrWriteFailure(installationId: numb
5757
return true;
5858
}
5959

60+
/** Test-only: clear the module-level installation health refresh cooldown so each test starts fresh. */
61+
export function clearInstallationHealthRefreshCooldownForTest(): void {
62+
installationHealthRefreshAttempts.clear();
63+
}
64+
6065
// A known-denied PR-write action (missing pull_requests:write) must not re-run the freshness + live-CI GitHub
6166
// calls and re-write an identical audit record on every sweep (#selfhost-runtime-drift) -- that burns queue/API
6267
// cycles on an outcome that cannot change until the maintainer re-consents (which itself only refreshes on the
@@ -334,9 +339,9 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE
334339
}
335340
} catch (error) {
336341
await audit("error", errorMessage(error));
337-
// RC3 terminal-fail merges: a merge that fails on perms (403/405) / required-check-absent (409) / a real
338-
// conflict can NEVER complete for this commit — mark it terminally merge-blocked so the planner stops
339-
// re-planning it every sweep. A possibly-transient failure is retried up to MERGE_RETRY_CAP then held.
342+
// RC3 terminal-fail merges: immediate terminal failures (401/405/409/conflict) are marked once; generic
343+
// GitHub 403s are retryable first because branch-protection/check/conversation state can converge shortly
344+
// after the gate publishes. A possibly-transient failure is retried up to MERGE_RETRY_CAP, then held.
340345
if (action.actionClass === "merge" && ctx.headSha) {
341346
await handleMergeFailure(env, ctx, error);
342347
}
@@ -557,11 +562,9 @@ export async function executeIssueMaintenanceActions(env: Env, ctx: IssueActionE
557562
return outcomes;
558563
}
559564

560-
// RC3: persist the outcome of a FAILED merge so it is never retried blindly forever. A non-transient failure
561-
// (403/405 perms, 409 required-check-absent, merge conflict) is terminal immediately; an otherwise-unclassified
562-
// failure (e.g. base moved during the merge — a benign TOCTOU race) is retried up to MERGE_RETRY_CAP and then
563-
// escalated to the same terminal hold. Either way the planner suppresses the merge for this head SHA and the PR
564-
// is held for a human (never auto-closed).
565+
// RC3: persist only TERMINAL failed-merge outcomes. Auth/policy/conflict failures are terminal immediately; a
566+
// generic GitHub 403 is not, because it also covers branch-protection/check/conversation convergence after the
567+
// bot publishes its own review/check. Retry those up to MERGE_RETRY_CAP before holding the PR for a human.
565568
async function handleMergeFailure(env: Env, ctx: AgentActionExecutionContext, error: unknown): Promise<void> {
566569
const headSha = ctx.headSha;
567570
/* v8 ignore next -- guarded at the call site; defensive. */

src/services/merge-failure.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { errorMessage } from "../utils/json";
99
// 401 inside the merge call itself, so a 401 reaching HERE means that retry also failed — a genuinely,
1010
// persistently unauthorized installation, not a one-off stale-token race. Burning the full MERGE_RETRY_CAP
1111
// against the same known-bad credential wastes calls for nothing; fail fast instead (#2264).
12-
// • 403 Resource not accessible by integration → the App lacks pull_requests:write / the branch is
13-
// protected against the App. A human must re-consent or merge.
12+
// • 403 Resource not accessible by integration → GitHub returned a generic branch-protection / ruleset /
13+
// installation-visibility rejection. The executor already checked the concrete App permissions before the
14+
// merge call, so this is retryable first: required checks, conversation resolution, and permission snapshots
15+
// can converge shortly after the review/check publication boundary.
1416
// • 405 Method Not Allowed → merge not allowed (e.g. required reviews/checks policy forbids an App merge).
1517
// • 409 Conflict → a required status check is absent / head moved into a non-mergeable state.
1618
// • merge-conflict text → the branch genuinely conflicts with base; only the contributor can resolve it.
@@ -20,8 +22,6 @@ import { errorMessage } from "../utils/json";
2022
// MERGE_RETRY_CAP before escalating to the same terminal hold.
2123
export const MERGE_RETRY_CAP = 5;
2224

23-
const TERMINAL_MERGE_STATUSES = new Set([403, 405, 409]);
24-
2525
/** True when the merge error TEXT describes a real content conflict (vs a behind-but-clean branch). */
2626
function isMergeConflictMessage(message: string): boolean {
2727
return /merge conflict|not mergeable|cannot be merged|has conflicts|conflicts? with the base/i.test(message);
@@ -33,6 +33,10 @@ function isBaseBranchMovedMessage(message: string): boolean {
3333
return /base branch was modified/i.test(message);
3434
}
3535

36+
function isConvergenceForbiddenMessage(message: string): boolean {
37+
return /resource not accessible by integration|secondary rate limit|api rate limit|abuse detection/i.test(message);
38+
}
39+
3640
/** Read the HTTP status off an Octokit RequestError (it sets `.status`); undefined for non-HTTP errors. */
3741
function httpStatus(error: unknown): number | undefined {
3842
const status = (error as { status?: unknown } | null | undefined)?.status;
@@ -46,13 +50,13 @@ export function classifyMergeFailure(error: unknown): { terminal: boolean; reaso
4650
const message = errorMessage(error);
4751
const status = httpStatus(error);
4852
if (status === 401) return { terminal: true, reason: `installation token rejected: App suspended or key rotated (401): ${message}` };
49-
if (status === 403) return { terminal: true, reason: `merge forbidden (403 — pull_requests:write or branch protection): ${message}` };
53+
if (status === 403 && isConvergenceForbiddenMessage(message)) return { terminal: false, reason: `merge forbidden for now (403 — branch protection or GitHub permission visibility may still be converging): ${message}` };
54+
if (status === 403) return { terminal: true, reason: `merge forbidden (403): ${message}` };
5055
// A 405 "Base branch was modified" is a benign TOCTOU race, not a policy rejection — retry against the new base
5156
// (the executor caps retries at MERGE_RETRY_CAP before escalating to the same terminal hold).
5257
if (status === 405 && isBaseBranchMovedMessage(message)) return { terminal: false, reason: `base branch moved during merge — retrying: ${message}` };
5358
if (status === 405) return { terminal: true, reason: `merge not allowed (405 — repo merge policy forbids an automated merge): ${message}` };
5459
if (status === 409) return { terminal: true, reason: `merge conflict / required check absent (409): ${message}` };
55-
if (status !== undefined && TERMINAL_MERGE_STATUSES.has(status)) return { terminal: true, reason: `merge rejected (${status}): ${message}` };
5660
if (isMergeConflictMessage(message)) return { terminal: true, reason: `branch conflicts with base — contributor must rebase: ${message}` };
5761
return { terminal: false, reason: message };
5862
}

test/unit/agent-action-executor.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { createInstallationToken } from "../../src/github/app";
4343
import { fetchLiveCiAggregate, refreshInstallationHealthForInstallation } from "../../src/github/backfill";
4444
import {
4545
actionParams,
46+
clearInstallationHealthRefreshCooldownForTest,
4647
clearWritePermissionDenialCooldownForTest,
4748
executeAgentMaintenanceActions,
4849
executeIssueMaintenanceActions,
@@ -93,6 +94,7 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
9394
liveHeadSha: args.expectedHeadSha ?? null,
9495
liveState: "open",
9596
}));
97+
clearInstallationHealthRefreshCooldownForTest();
9698
clearWritePermissionDenialCooldownForTest();
9799
resetMetrics();
98100
});
@@ -309,7 +311,7 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
309311
it("LIVE merge is denied when live CI has since turned failing (#2128)", async () => {
310312
const env = createTestEnv({});
311313
vi.mocked(fetchLiveCiAggregate).mockResolvedValueOnce({ ciState: "failed", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [], ciCompletenessWarning: null });
312-
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]);
314+
const outcomes = await executeAgentMaintenanceActions(env, ctx({ installationId: 127 }), [merge]);
313315
expect(outcomes[0]?.outcome).toBe("denied");
314316
expect(outcomes[0]?.detail).toContain("live CI is no longer passing (now: failed)");
315317
expect(mergePullRequest).not.toHaveBeenCalled();
@@ -730,6 +732,26 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
730732
expect((await auditFor(env, "merge"))?.outcome).toBe("error");
731733
});
732734

735+
it("REGRESSION: a generic GitHub 403 merge rejection does not immediately pin merge_blocked_sha", async () => {
736+
const env = createTestEnv({});
737+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "c" }, head: { sha: "sha7" }, labels: [], body: "" });
738+
vi.mocked(mergePullRequest).mockRejectedValueOnce(Object.assign(new Error("Resource not accessible by integration"), { status: 403 }));
739+
740+
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]);
741+
742+
expect(outcomes[0]).toMatchObject({ actionClass: "merge", outcome: "error" });
743+
const row = await env.DB.prepare(
744+
"select merge_attempt_count as mergeAttemptCount, merge_blocked_sha as mergeBlockedSha, merge_blocked_reason as mergeBlockedReason from pull_requests where repo_full_name = ? and number = ?",
745+
)
746+
.bind("owner/repo", 7)
747+
.first<{ mergeAttemptCount: number; mergeBlockedSha: string | null; mergeBlockedReason: string | null }>();
748+
expect(row).toEqual({ mergeAttemptCount: 1, mergeBlockedSha: null, mergeBlockedReason: null });
749+
const blocked = await env.DB.prepare("select count(*) as count from audit_events where event_type = ?")
750+
.bind("agent.action.merge_blocked")
751+
.first<{ count: number }>();
752+
expect(blocked?.count).toBe(0);
753+
});
754+
733755
it("opportunistically refreshes installation health when a PR-write mutation fails with a 403 (#2265)", async () => {
734756
const env = createTestEnv({});
735757
vi.mocked(closePullRequest).mockRejectedValueOnce(Object.assign(new Error("Resource not accessible by integration"), { status: 403 }));

test/unit/merge-failure.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,16 @@ describe("classifyMergeFailure", () => {
2929
expect(result.reason).toMatch(/suspended or key rotated/i);
3030
});
3131

32-
it("treats 403, 409, and real merge-conflict text as terminal", () => {
33-
expect(classifyMergeFailure(httpError(403, "Resource not accessible by integration")).terminal).toBe(true);
32+
it("retries GitHub's generic 403 merge rejection because branch protection can still converge", () => {
33+
for (const message of ["Resource not accessible by integration", "secondary rate limit", "API rate limit exceeded", "abuse detection mechanism triggered"]) {
34+
const result = classifyMergeFailure(httpError(403, message));
35+
expect(result.terminal).toBe(false);
36+
expect(result.reason).toMatch(/converging/i);
37+
}
38+
});
39+
40+
it("treats non-convergence 403s, 409, and real merge-conflict text as terminal", () => {
41+
expect(classifyMergeFailure(httpError(403, "Repository does not allow squash merges")).terminal).toBe(true);
3442
expect(classifyMergeFailure(httpError(409, "Required status check is expected.")).terminal).toBe(true);
3543
expect(classifyMergeFailure(new Error("The branch has conflicts that must be resolved")).terminal).toBe(true);
3644
});

0 commit comments

Comments
 (0)