Skip to content

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

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

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
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR fixes CLI merge-path behavior, but the linked issue is a release/versioning PR for @runfusion/fusion@0.1.1, so the requested objective is not addressed. Implement the versioning/release workflow for @runfusion/fusion and the version.yml/release.yml behavior described in issue #2.
Out of Scope Changes check ⚠️ Warning The merge-path fixes and tests are unrelated to the linked release/versioning work, so the code changes are out of scope for issue #2. Either retarget this PR to the release/versioning scope or update the linked issue to match the CLI merge-path bugfix work.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: hardening the CLI PR merge path for empty-diff no-ops and conflicting PR retry escape.
✨ 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

@gsxdsm gsxdsm marked this pull request as ready for review July 11, 2026 01:22
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR updates the CLI PR merge lifecycle for stale-base edge cases. The main changes are:

  • Empty-diff PR creation now finalizes the task as no-op done.
  • Conflicting PR polls now increment mergeRetries.
  • Tests cover conflicting and non-conflicting PR wait behavior.

Confidence Score: 4/5

The shared branch-group no-op path can leave sibling tasks in an invalid lifecycle state.

  • Individual empty-diff tasks now complete cleanly.
  • Conflicting PR wait handling is bounded by the existing retry counter.
  • Shared branch groups need a fix so all members complete consistently when the group PR has no diff.

packages/cli/src/commands/task-lifecycle.ts; add a changeset for the published CLI fix.

Important Files Changed

Filename Overview
packages/cli/src/commands/task-lifecycle.ts Adds no-op finalization and conflicting-PR retry increments; the shared branch-group no-op path can leave sibling members open.
packages/cli/src/commands/tests/task-lifecycle.test.ts Adds focused tests for retry increments and individual empty-diff completion, but not the branch-group empty-diff path.

Reviews (1): Last reviewed commit: "fix(cli): harden dormant PR merge path a..." | Re-trigger Greptile

await store.updateBranchGroup(branchGroup.id, { prState: "none", prNumber: null, prUrl: null });
await store.updateTask(task.id, { status: "failed", error: `No pull request created for ${branchGroup.branchName}: no commits relative to ${projectDefaultBranch}.` });
await store.logEntry(task.id, "No group pull request created", message);
await finalizeNoOpMergeTask(store, cwd, task, "No group pull request created (no commits vs base) — finalizing as no-op", pool);

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.

P1 Group No-Op Leaves Members Open

When a shared branch-group PR has no commits relative to the base, this branch clears the group PR state but finalizes only the current task. Sibling members can remain in in-review with the shared group reset to prState: "none", so later lifecycle passes can recreate or split the group flow instead of completing the whole no-op group together.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

* slot + file leases indefinitely; a no-op branch is not a failure — there was
* simply nothing to merge.
*/
async function finalizeNoOpMergeTask(

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.

P2 Published Fix Has No Changeset

This CLI change affects published @runfusion/fusion behavior by moving empty-diff PR tasks to no-op done and by bounding conflicting PR waits. The repository release rules require a patch changeset for published package fixes, so without one this operator-facing fix can be missed by versioning and release notes.

Context Used: AGENTS.md (source)

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