Skip to content

fix(cli): harden PR merge path against stale-base dead-ends (empty-diff no-op + conflict escape)#1943

Draft
flexi767 wants to merge 1 commit into
Runfusion:mainfrom
flexi767:fix/dormant-pr-noop-and-conflict-escape
Draft

fix(cli): harden PR merge path against stale-base dead-ends (empty-diff no-op + conflict escape)#1943
flexi767 wants to merge 1 commit into
Runfusion:mainfrom
flexi767:fix/dormant-pr-noop-and-conflict-escape

Conversation

@flexi767

@flexi767 flexi767 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Two stale-base failure modes wedge the PR-based merge flow (processPullRequestMergeTask in packages/cli/src/commands/task-lifecycle.ts). Both pin the serial merge slot + file-scope leases on a dead-end task, starving ready work behind it.

  1. Empty-diff branchcreatePr throws No commits between <base> and <head>. The task was marked failed, even though a zero-commit branch is a legitimate no-op, not a failure. This is the PR-path analog of the engine-path fix in fix(engine): short-circuit zero-commits-ahead branch before AI-merge clean-room churn #1920 (noOpResult → done).

  2. Conflicting stale-base PR — a PR GitHub reports as CONFLICTING never becomes mergeable on its own (nothing in the PR path rebases the head branch). The task sat in awaiting-pr-checks unbounded: awaiting-pr-checks is not in TRANSIENT_MERGE_STATUSES, so the age-based stall escape never catches it, and the only other escape — getInReviewStallReason's merge-retries-exhausted — never fired because the PR path never incremented mergeRetries.

Fix

  1. finalizeNoOpMergeTask finalizes empty-diff branches as a terminal DONE, mirroring the engine's canonical noOpResult decision (merged:false, noOp:true). Both the group and per-task empty-diff sites route through it.

  2. Each CONFLICTING poll now counts against mergeRetries, so the existing merge-retries-exhausted escape disposes the task after maxAutoMergeRetries cycles. Detection is gated on prInfo.mergeable === "conflicting" (the genuine unresolvable signal), not blockingReasons (status/review/checks only). Pending / behind PRs still wait indefinitely — checks legitimately run, and behind is resolved by the pre-merge rebase.

No new machinery — fix #2 reuses the existing stall-escape counter rather than a hand-rolled fail branch.

Tests

packages/cli/src/commands/__tests__/task-lifecycle.test.ts:

  • empty-diff branch → no-op DONE (moveTask(id,"done"), updateTask({status:null,mergeRetries:0}), never failed)
  • conflicting not-ready PR → mergeRetries bumped
  • non-conflicting not-ready PR → mergeRetries not bumped

pnpm --filter @runfusion/fusion test (task-lifecycle suite 39/39), typecheck, and build all green.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Tasks with no commits to merge now finish cleanly instead of being marked as failed.
    • Merge checks now handle conflicting or unstable merge states more reliably, reducing unnecessary retries and improving task progression.

Two failure modes wedged the PR-based merge flow (dormant under the
current direct-merge config, but the rollback target):

1. Empty-diff branches ("No commits between ...") were marked `failed`,
   pinning the serial merge slot + file leases on a task that is a
   legitimate no-op. Now finalized as a terminal DONE via
   `finalizeNoOpMergeTask`, mirroring the engine's canonical
   `noOpResult` decision (merger-ai.ts).

2. A stale-base PR that GitHub reports CONFLICTING never becomes
   mergeable on its own (nothing in the PR path rebases the head), so
   `awaiting-pr-checks` waited unbounded — no escape, because the PR
   path never incremented `mergeRetries`. Now each conflicting poll
   counts against `mergeRetries`, so the existing
   `getInReviewStallReason` "merge-retries-exhausted" escape disposes
   the task after `maxAutoMergeRetries` cycles. Pending/behind PRs
   still wait (checks legitimately run; "behind" is fixed by rebase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d05dd0cd-3f24-4b38-81f8-a0c69f5cea39

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/__tests__/task-lifecycle.test.ts`:
- Around line 671-736: The current tests cover only the per-task PR path, but
the same invariant must also be verified on the shared-branch-group path. Add
regression coverage in the task lifecycle tests for the shared-group surface by
exercising the shared-group finalization flow that handles the “No commits
between” case and asserting it resolves as a no-op done outcome, and also cover
the shared-group not-ready/conflicting handling if the task-lifecycle logic now
routes there. Use the existing task lifecycle helpers and the shared-group
branch-group entrypoints in task-lifecycle.test.ts to locate the relevant flows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a14e2bf-7565-434f-8341-e82c522611b5

📥 Commits

Reviewing files that changed from the base of the PR and between ca84473 and f813e7d.

📒 Files selected for processing (2)
  • packages/cli/src/commands/__tests__/task-lifecycle.test.ts
  • packages/cli/src/commands/task-lifecycle.ts

Comment on lines +671 to +736
it("counts a conflicting stale-base PR against mergeRetries so the stall escape fires", async () => {
const task: MockTask = {
id: "FN-9020",
title: "test",
description: "desc",
column: "in-review",
mergeRetries: 1,
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
execMock.mockImplementation(() => "");

const existingPr = { number: 9, url: "https://github.com/x/y/pull/9", status: "open" as const, headBranch: branch, baseBranch: "main" };
const github = {
findPrForBranch: vi.fn(async () => existingPr),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { ...existingPr, mergeable: "conflicting" as const },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};

const result = await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);

expect(result).toBe("waiting");
expect(store.updateTask).toHaveBeenCalledWith(task.id, {
status: "awaiting-pr-checks",
mergeRetries: 2,
});
});

it("does not bump mergeRetries for a non-conflicting not-ready PR", async () => {
const task: MockTask = {
id: "FN-9021",
title: "test",
description: "desc",
column: "in-review",
mergeRetries: 1,
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
execMock.mockImplementation(() => "");

const existingPr = { number: 10, url: "https://github.com/x/y/pull/10", status: "open" as const, headBranch: branch, baseBranch: "main" };
const github = {
findPrForBranch: vi.fn(async () => existingPr),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { ...existingPr, mergeable: "unknown" as const },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};

const result = await processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined);

expect(result).toBe("waiting");
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: "awaiting-pr-checks" });
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add regression coverage for the shared-branch-group surface.

Both new tests (and the reworked no-delta test) exercise only the per-task PR creation/merge path. The same two behaviors also have a shared-branch-group surface: no-op finalization on "No commits between" (Line 799-812) and the not-ready/conflicting handling (Line 858-861). Please enumerate and assert the invariant on the shared-group surface too — at minimum a shared-group "No commits between" → no-op done case, and (if the gap flagged in task-lifecycle.ts is addressed) a shared-group conflicting case.

As per coding guidelines: "When fixing a bug, regression tests must assert the general invariant across all known surfaces, and bug-fix work must include symptom verification and surface enumeration rather than only the single reproduction."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/__tests__/task-lifecycle.test.ts` around lines 671
- 736, The current tests cover only the per-task PR path, but the same invariant
must also be verified on the shared-branch-group path. Add regression coverage
in the task lifecycle tests for the shared-group surface by exercising the
shared-group finalization flow that handles the “No commits between” case and
asserting it resolves as a no-op done outcome, and also cover the shared-group
not-ready/conflicting handling if the task-lifecycle logic now routes there. Use
the existing task lifecycle helpers and the shared-group branch-group
entrypoints in task-lifecycle.test.ts to locate the relevant flows.

Source: Coding guidelines

@gsxdsm

gsxdsm commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

This looks good please update to ready for review when comfortable

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants