fix(controller): scope conflict-marker check to resolved files

finalize_conflict_resolution grepped every file in the PR's
origin/<base>..HEAD diff for committed conflict markers. On a large
sentinel PR (478-file diff) that includes .opencode/agents/
git-rebase-util.md — an agent def that *documents* conflict markers
with literal "<<<<<<< HEAD" lines — so the check false-positived and
rejected every resolution, looping the conflict-resolver indefinitely
(run-9: 185 attempts in ~4h, 125 on this exact error).

Scope the grep to the files the resolution actually touched
(resolved_files = prep-time conflicted set + the agent's reported
files_modified). A leftover marker can only be in a file the agent
resolved; a clean rebase/merge resolves nothing so the check is
skipped. Adds a regression test with a marker-documenting file in the
PR diff that is not a resolved file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 21:33:44 -04:00
parent 615a05b982
commit d4e1219577
2 changed files with 68 additions and 20 deletions
@@ -36,7 +36,7 @@ def _git(cwd, *args: str) -> str:
).stdout
def _build(tmp_path, *, conflicting: bool, extra_master_file=None):
def _build(tmp_path, *, conflicting: bool, extra_master_file=None, extra_pr_file=None):
"""Bare remote + a conflict-resolver worktree checked out at a PR
branch that, rebased onto master, either conflicts or doesn't.
@@ -76,6 +76,13 @@ def _build(tmp_path, *, conflicting: bool, extra_master_file=None):
(seed / "shared.txt").write_text("PR version of the line\n")
else:
(seed / "prfile.txt").write_text("a brand-new PR-only file\n")
# An extra file the PR branch itself carries — it lands IN the PR's
# diff vs master (unlike extra_master_file, which is in the base).
if extra_pr_file is not None:
name, content = extra_pr_file
path = seed / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
_git(seed, "add", ".")
_git(seed, "commit", "-m", "pr change")
_git(seed, "push", "origin", "pr-branch")
@@ -218,6 +225,7 @@ class TestFinalizeConflictResolution:
head_ref="pr-branch",
expected_remote_sha=pr_head,
base_branch="master",
resolved_files=["shared.txt"],
)
def test_unrelated_marker_file_does_not_false_positive(self, tmp_path):
@@ -239,11 +247,41 @@ class TestFinalizeConflictResolution:
head_ref="pr-branch",
expected_remote_sha=pr_head,
base_branch="master",
resolved_files=["shared.txt"],
)
# The clean resolution landed — the unrelated marker file was
# outside the PR's diff and correctly ignored.
assert _git(bare, "rev-parse", "pr-branch").strip() == new_sha
def test_marker_file_in_pr_diff_but_unresolved_does_not_false_positive(
self, tmp_path
):
"""Production regression (run-9: 125 false rejections): a file
the PR itself carries that legitimately contains conflict-marker
text (e.g. an agent def documenting markers) is IN the PR's diff
but is NOT a resolved file. The marker check must grep only what
the resolution actually touched and ignore it."""
bare, worktree, pr_head = _build(
tmp_path,
conflicting=True,
extra_pr_file=(
"agents/git-rebase-util.md",
"Conflict markers look like:\n<<<<<<< HEAD\nx\n>>>>>>> branch\n",
),
)
prepare_conflict_rebase(worktree, base_branch="master")
_resolve_and_continue(worktree) # cleanly resolves shared.txt
new_sha = finalize_conflict_resolution(
worktree,
head_ref="pr-branch",
expected_remote_sha=pr_head,
base_branch="master",
resolved_files=["shared.txt"],
)
# The marker-doc file is in the PR diff but not in resolved_files
# → ignored → the clean resolution landed.
assert _git(bare, "rev-parse", "pr-branch").strip() == new_sha
# ─── synth_resolved_output ────────────────────────────────────────────
@@ -532,6 +570,7 @@ class TestFinalizeMergeTrack:
expected_remote_sha=pr_head,
base_branch="master",
track="merge",
resolved_files=["shared.txt"],
)
+28 -19
View File
@@ -352,6 +352,7 @@ def finalize_conflict_resolution(
expected_remote_sha: str,
base_branch: str,
track: str = "rebase",
resolved_files: list[str] | None = None,
) -> str:
"""Verify the agent's resolution and land it on the PR head branch.
@@ -370,6 +371,12 @@ def finalize_conflict_resolution(
forwardable superset of the old tip); a force-push would be
*wrong*, so it uses a **plain** ``git push``.
``resolved_files`` is the set of files this resolution actually
touched. The committed-conflict-marker check greps ONLY those — a
leftover marker can only be in a file the agent resolved.
``None``/empty skips that check (a clean rebase/merge resolved
nothing).
Raises ``RuntimeError`` if the resolution is incomplete (rebase/
merge not finished, unmerged files, conflict markers committed,
dirty tree) OR the push is rejected / cannot be confirmed on the
@@ -393,27 +400,19 @@ def finalize_conflict_resolution(
f"worktree not clean after resolution: {status[:200]}"
)
# git status is clean even if the agent *committed* conflict-marker
# garbage — catch that. Scope the grep to the PR's own changed
# files: a whole-tree grep false-positives on any repo file that
# legitimately documents conflict markers (e.g. a git-rebase agent
# def), which would reject *every* resolution.
#
# ``origin/<base>..HEAD`` is the PR's changed-file set for BOTH
# tracks: a rebased HEAD sits directly on origin/<base>, and a merged
# HEAD has origin/<base> as a parent — so in either case
# origin/<base> is an ancestor of HEAD and this endpoint diff is
# exactly the PR's contribution.
changed = [
ln.strip()
for ln in _git(
worktree, "diff", "--name-only", f"origin/{base_branch}..HEAD"
).splitlines()
if ln.strip()
]
# garbage — catch that. Grep ONLY the files this resolution actually
# touched (``resolved_files``): a leftover marker can only be in a
# file the agent resolved. Grepping the whole PR diff false-positived
# on any unrelated file that legitimately contains marker-like lines
# — e.g. an agent def that *documents* conflict markers — which on a
# large sentinel PR's 400+-file diff rejected every resolution.
# ``None``/empty (a clean rebase/merge — nothing was conflicted)
# means there is nothing to check.
if (
changed
resolved_files
and _git_rc(
worktree, "grep", "-lE", r"^(<<<<<<<|>>>>>>>) ", "HEAD", "--", *changed
worktree, "grep", "-lE", r"^(<<<<<<<|>>>>>>>) ", "HEAD",
"--", *resolved_files,
)
== 0
):
@@ -567,12 +566,15 @@ def run_conflict_resolver_attempt(
"-m",
f"Merge origin/{base_branch} into {head_ref}",
)
# Clean prep — no conflicts were resolved, so there is no
# committed-marker check to do.
new_sha = finalize_conflict_resolution(
worktree,
head_ref=head_ref,
expected_remote_sha=head_sha,
base_branch=base_branch,
track=track,
resolved_files=[],
)
except RuntimeError as exc:
_abort(track)
@@ -606,6 +608,12 @@ def run_conflict_resolver_attempt(
# 3. The agent only decides the merge — the worker lands it.
if result.get("outcome") == "resolved":
_check_lock()
# The committed-marker check greps only what this resolution
# actually touched: the prep-time conflicted set (controller-
# trusted) plus whatever files the agent reports it modified.
resolved = sorted(
set(conflicted) | set(result.get("files_modified") or [])
)
try:
new_sha = finalize_conflict_resolution(
worktree,
@@ -613,6 +621,7 @@ def run_conflict_resolver_attempt(
expected_remote_sha=head_sha,
base_branch=base_branch,
track=track,
resolved_files=resolved,
)
except RuntimeError as exc:
_abort(track)