AI

让 7 号机当合并员:三台开发机的代理 PR 怎么一个个安全进主干(中英切换)

This post is currently available in Chinese only. The original text follows.Open the Chinese page →

还是那个三人团队、七台电脑。1、2、3 号机上的代理各自从主干拉分支干活,4、5 号机审查,6 号机做 Windows 验证,7 号机一直没有固定的活。

周二下午,三台开发机差不多同时交了 PR。1 号机做的是重构:把 calc_total 改名成 order_total,顺便加了税率参数,并把仓库里所有调用点都改了。2 号机加了一个 export 命令,代码里调用的是 calc_total,因为它拉分支的时候,主干上就叫这个名字。3 号机加了 discount 命令,和 2 号机一样,在命令注册表和 CHANGELOG 的末尾各加了一行。三个 PR 的 CI 都是绿的,审查机也都过了。GitHub 的 PR 页面上,三个都显示 MERGEABLE / CLEAN,合并按钮是绿的。

先合 1 号机的,主干是绿的。再合 2 号机的,没有冲突,主干直接红了:ImportError: cannot import name 'calc_total',整个测试文件导入就失败。3 号机的 PR 这时又冲突了,冲突落在 CHANGELOG.md 和 shop/registry.py,就是大家都往末尾加一行的那两个文件。

这个场景不是我编的,后面会讲到,它是在一个真实的 GitHub 仓库里用三个真实的 PR 跑出来的。我的判断:三台机器并行出 PR,就不能再“谁 review 完谁点合并”了。合并这件事应该交给一台机器,按顺序一个一个合,每个 PR 都在“当前主干加上它”的组合上跑一遍测试,没通过就退回给开发机。 这台机器就让一直空着的 7 号机来当。另外,要从结构上拆掉几个“人人都往末尾加一行”的文件,否则合并员会把大部分时间花在退回冲突上。

下面有四个实验:本地 git 仓库里的模拟,在真实 GitHub PR 上跑的合并列车,对一个热门开源仓库现有 60 个打开 PR 的两两试合并,以及几种“避免冲突”写法的对比。脚本都附在文中,也打包放在文末。

为什么每个 PR 都是绿的,主干还是红了

先说清楚 CI 到底测了什么。GitHub Actions 的 pull_request 事件检出的是 refs/pull/N/merge,也就是“这个 PR 和触发那一刻的主干”合并出来的提交(官方文档)。所以 CI 的绿,意思是“和十分钟前的主干合在一起没问题”。从那之后又合进去的 PR,它没见过。

PR 页面上的 MERGEABLE 也只说明一件事:git 做三方合并时没有文本冲突。它不编译,也不跑测试。1 号机改的是 pricing.py 和 total.py,2 号机新增的是 export.py,两边没有改同一行,git 当然认为能合。可是 2 号机新写的那行 from shop.pricing import calc_total,依赖的是一个刚被 1 号机删掉的名字。这种冲突叫语义冲突:文本合得上,含义合不上。能发现它的只有一种办法,就是在合并之后的那棵树上真跑一遍测试。

人工开发时这个问题也有,只是不常见。一个人一天开一两个 PR,PR 之间隔得开,大家大致知道别人在改什么。代理把三个条件同时推到了极端。第一,并行度高:三台机器同时从同一个主干提交拉分支,各干各的,谁也不知道别人在改什么。第二,改动面宽:代理做重构会把全仓库的调用点一次改完,加功能时又会顺手更新注册表、CHANGELOG、依赖文件和文档索引。第三,到达集中:代理干活快,PR 往往在一个小时里扎堆提交,审查机审完也是扎堆放行。三个 PR 都从同一个提交出发,在同一个小时里合并,CI 结果互相看不到对方。

实验一:本地模拟,照默认方式合并

我先在本地 git 仓库里把上面的场景做出来:一个很小的 Python 订单命令行工具,三个分支,分别对应三台开发机的改动。合并方式模拟 GitHub 的 squash merge:每个 PR 先在自己分支上跑测试,然后压缩合并到当时的主干,合并后不再重测。这正是没有分支保护的仓库每天在做的事。下面是原始输出:

== PR m1/rename-total: CI on its own branch
3 passed in 0.01s
== squash-merge m1/rename-total onto main ab1c305
   merged clean -> 6a8135e
== tests on main after merging m1/rename-total
3 passed in 0.01s
== PR m2/export-cmd: CI on its own branch
3 passed in 0.01s
== squash-merge m2/export-cmd onto main 6a8135e
   merged clean -> 862b94c
== tests on main after merging m2/export-cmd
E   ImportError: cannot import name 'calc_total' from 'shop.pricing'
== PR m3/discount-cmd: CI on its own branch
3 passed in 0.01s
== squash-merge m3/discount-cmd onto main 862b94c
   CONFLICT (content): Merge conflict in CHANGELOG.md
   CONFLICT (content): Merge conflict in shop/registry.py

两类问题都出现了。语义冲突让主干变红,而且是悄无声息地红:合并按钮是绿的,没人会在合并后立刻去跑测试。文本冲突更吵,但也更好处理,它至少会拦住你。真正麻烦的是第一类,红掉的主干会让之后从主干拉分支的每一个代理都在一个坏的基础上开工,于是 1、2、3 号机接下来的 PR 都会莫名其妙地挂测试,代理还会试图“修”一个不是自己弄坏的东西。

实验二:一个真实仓库此刻有多少 PR 互相冲突

本地模拟只能说明机制,不能说明这事常见。我找了一个 PR 流量很大的开源仓库 anomalyco/opencode,在 2026-10-22 18:45(台北时间)把最新的 60 个打开的 PR 全部抓下来,用 git merge-tree 两两做试合并。这个命令不需要检出,只在对象库里算出合并结果,冲突就返回非零。

opencode 60 个打开 PR:14 个已经和主干冲突;其余 46 个之间有 12 对互相冲突,其中 10 对落在 ecosystem.mdx 这一张插件表上;被 3 个以上 PR 同时修改的文件包括 ecosystem.mdx、bun.lock、i18n/en.ts、package.json

60 个 PR 里,14 个已经和主干冲突,作者得先 rebase。剩下 46 个此刻都能干净地合进主干,可它们之间有 12 对互相冲突:只要其中一个先合进去,另一个就会变成冲突。12 对里有 10 对落在同一个文件 packages/web/src/content/docs/ecosystem.mdx 上。我打开看了,它是一张社区插件的 Markdown 表格,每个 PR 都往表尾加一行。按 GitHub API 统计,同时被 3 个以上打开 PR 修改的文件还有 bun.lock、i18n/en.ts、package.json,也是注册表、锁文件、文案表这一类“大家都要加一笔”的地方。

这是一个社区项目,PR 不一定是代理写的,所以这组数字不能直接套到你的团队上。它说明的是冲突怎么分布:至少在这个仓库里,互相冲突的 PR 很少撞在复杂的业务代码上,绝大多数撞在一两个“共享清单”文件上。这对三台开发机的团队是好消息,因为这几个文件可以一次改掉。

为什么不直接用 GitHub 的合并队列

GitHub 有现成的东西做这件事,叫 merge queue:PR 进队列以后,GitHub 把它和排在前面的 PR 合成一个临时分支,在上面跑必过检查,通过了才合进主干。原理和本文的方案一样。问题是能不能用。按官方文档,它只对“组织名下的公开仓库”和“使用 Enterprise Cloud 的组织私有仓库”开放。很多三五个人的小团队,仓库就挂在某个人的个人账号下,而且是私有的。我拿自己的仓库试了一下,连规则集接口都拿不到:

$ gh api repos/shuizhengqi1/pages/rulesets
gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)

退一步,分支保护里有一个“合并前要求分支是最新的”选项。它确实能防住语义冲突,因为每个 PR 都得先 rebase 到最新主干、重跑 CI 才能合。可它会造成 rebase 风暴:每合进一个 PR,其他所有待合并的 PR 都过期了,要重新 rebase、重新跑 CI。假设 6 个 PR 同时审完,要全部合进去,CI 要跑 6+5+4+3+2+1=21 次,而一个一个排着测只要 6 次。这是算出来的,不是测的,但道理很直接。更糟的是,rebase 往往由开发机上的代理去做,每次都要占用它的上下文,还可能被它顺手“改进”几行。

所以对一个个人账号下的私有仓库,最实际的做法是自己做一个最小的合并队列。它不用改 PR 分支,不用让代理 rebase,只在合并员自己的机器上把“主干 + PR”合成候选提交测一遍。

方案:7 号机当合并员

核心是一个不碰任何工作区的脚本。它用 git merge-tree --write-tree(git 2.38 起才有)算出“主干 + 这个 PR”压缩合并后的那棵树,用 git commit-tree 包成一个候选提交,检出到临时 worktree 里跑测试。冲突返回 10,测试红返回 20,通过就打印候选提交和树的哈希:

train-step.sh

#!/usr/bin/env bash
# train-step.sh <base-ref> <head-ref>
# Test what main WOULD look like after squash-merging <head-ref>, without touching
# any checkout. Needs git >= 2.38 (merge-tree --write-tree).
#   exit 0  -> prints "<candidate-commit> <tree>"; tests passed on the combination
#   exit 10 -> textual conflict; prints the conflicted paths
#   exit 20 -> merges clean but TEST_CMD fails on the combination; log in $TRAIN_LOG
set -uo pipefail
base=$(git rev-parse "$1") head=$(git rev-parse "$2")
TEST_CMD=${TEST_CMD:-"python3 -m pytest -q"}
TRAIN_LOG=${TRAIN_LOG:-$(mktemp)}

if ! out=$(git merge-tree --write-tree --name-only --no-messages "$base" "$head"); then
  echo "$out" | tail -n +2        # line 1 is the (conflicted) tree id
  exit 10
fi
tree=$(echo "$out" | head -1)
cand=$(git commit-tree "$tree" -p "$base" -m "train candidate: $2")

wt=$(mktemp -d)
git worktree add -q --detach "$wt" "$cand"
( cd "$wt" && eval "$TEST_CMD" ) >"$TRAIN_LOG" 2>&1; rc=$?
git worktree remove --force "$wt"
[ $rc -eq 0 ] || exit 20
echo "$cand $tree"

外面再包一层,让它对 GitHub 上打了 queue 标签的 PR 逐个执行。这个标签可以由 4、5 号机审查通过后打上,和前天那篇里的标签流转接在一起。合并员做四件事:按 PR 编号顺序取队列;冲突或测试红就摘掉 queue,打上 train:conflict 或 train:red,并把冲突文件或测试输出贴成评论退回去;通过就用 gh pr merge --squash --match-head-commit 合并,这个参数保证测过的就是要合的那个提交,开发机中途又推了代码就拒绝合并;合并后再比对主干的树和测过的树是否一致。同一个 PR 被退回三次以后,改打 train:human,不再让代理和合并员来回推。

merge-train.sh

#!/usr/bin/env bash
# merge-train.sh — run on machine 7 inside a clone of the repo. Merges PRs labelled
# `queue` one at a time, each tested on top of the CURRENT base branch.
# Works on a personal private repo (no merge queue, no branch protection needed).
#   BASE=main TEST_CMD="npm test" ./merge-train.sh          # one pass
#   while sleep 120; do ./merge-train.sh; done              # machine 7 loop
set -uo pipefail
BASE=${BASE:-main}
MAX_BOUNCES=${MAX_BOUNCES:-3}
export TEST_CMD=${TEST_CMD:-"python3 -m pytest -q"}
STEP="$(cd "$(dirname "$0")" && pwd)/train-step.sh"
MARK='<!-- merge-train -->'

bounce() {  # bounce <pr> <label> <message-file>
  local pr=$1 label=$2 msg=$3 n
  n=$(gh pr view "$pr" --json comments --jq "[.comments[].body|select(contains(\"$MARK\"))]|length")
  if [ "$n" -ge "$MAX_BOUNCES" ]; then label=train:human; fi   # stop the agent ping-pong
  gh pr edit "$pr" --remove-label queue --add-label "$label" >/dev/null
  { echo "$MARK"; cat "$msg"; } | gh pr comment "$pr" --body-file - >/dev/null
  echo "BOUNCED #$pr -> $label"
}

for pr in $(gh pr list --base "$BASE" --label queue --state open --json number --jq 'sort_by(.number)|.[].number'); do
  git fetch -q origin "$BASE" "+refs/pull/$pr/head:refs/train/pr-$pr" || continue
  base_sha=$(git rev-parse "origin/$BASE"); head_sha=$(git rev-parse "refs/train/pr-$pr")
  export TRAIN_LOG=$(mktemp); msg=$(mktemp)
  res=$("$STEP" "$base_sha" "$head_sha"); rc=$?
  case $rc in
  10) { echo "**Conflicts with \`$BASE\` @ ${base_sha:0:7}** in:"; echo "$res" | sed 's/^/- `/; s/$/`/'
        echo; echo "Rebase on \`$BASE\`, keep both sides' intent, push; then re-add \`queue\`."; } >"$msg"
      bounce "$pr" train:conflict "$msg"; continue;;
  20) { echo "**Green on its own branch, red on top of \`$BASE\` @ ${base_sha:0:7}.** Tail of \`$TEST_CMD\`:"
        echo '```'; tail -n 25 "$TRAIN_LOG"; echo '```'
        echo "Something merged since you branched changes what this PR relies on. Fix on top of \`$BASE\`, then re-add \`queue\`."; } >"$msg"
      bounce "$pr" train:red "$msg"; continue;;
  0)  tested_tree=${res#* };;
  *)  echo "train-step failed for #$pr (rc=$rc)"; continue;;
  esac

  # Compare-and-swap: only merge if nobody moved BASE or pushed to the PR meanwhile.
  [ "$(git ls-remote origin "refs/heads/$BASE" | cut -f1)" = "$base_sha" ] || { echo "#$pr: $BASE moved, retry next pass"; continue; }
  if ! gh pr merge "$pr" --squash --match-head-commit "$head_sha" >/dev/null; then
    echo "#$pr: merge refused (new push?), retry next pass"; continue
  fi
  git fetch -q origin "$BASE"
  merged_tree=$(git rev-parse "origin/$BASE^{tree}")
  if [ "$merged_tree" = "$tested_tree" ]; then echo "MERGED  #$pr  tree ${tested_tree:0:7} == tested"
  else echo "WARNING #$pr: $BASE tree ${merged_tree:0:7} != tested ${tested_tree:0:7} (someone merged by hand?)"; fi
done

我在自己的仓库里开了一个临时分支当主干,推上三个和实验一相同的分支,开了三个真实的 PR(#30、#31、#32),打上 queue,然后在盒子上运行这个脚本。GitHub 在运行前显示三个 PR 都是 MERGEABLE / CLEAN。

对比:默认合并时 #31 合并干净但主干 ImportError、#32 冲突;合并列车中 #30 通过合并、#31 被退回 train:red、#32 通过合并,2 号机修好后第二轮合并,主干始终是绿的

第一次运行就出了问题,问题在我自己的脚本里。#30 顺利合并,轮到 #31 时脚本直接退出了:

MERGED  #30  tree 97e866c == tested
../../kit/merge-train.sh: line 31: TEST_CMD: unbound variable

测试红的那条分支要在评论里写出 TEST_CMD,可我只在 train-step.sh 里给了它默认值。脚本开了 set -u,变量没定义就退出了。结果 #31 还挂着 queue,没被退回,这一轮剩下的 PR 也没处理。好在脚本是幂等的,补上默认值后再跑一次就接着往下走。这件事的教训是:合并员上线前,一定要故意喂它一个会红的 PR,把退回这条路实际走一遍。只走过合并成功那条路,等于没测过。修好后的第二次运行:

BOUNCED #31 -> train:red
MERGED  #32  tree 7c0afb6 == tested

#31 收到的评论开头是这样的,完整的测试输出也一起贴上去了,2 号机的代理拿到就知道改哪里:

**Green on its own branch, red on top of `lab/train-2026-10-22` @ 6640300.**
...
shop/commands/export.py:2: in <module>
    from shop.pricing import calc_total
E   ImportError: cannot import name 'calc_total' from 'shop.pricing'

接着我模拟 2 号机的代理修复:rebase 到新的主干(这时 #32 已经合进去,CHANGELOG.md 和 registry.py 两处冲突,保留两边各自的那一行),把 calc_total 改成 order_total,本地测试通过后强推。推之前,我先拿旧的提交哈希试了一次合并,确认 --match-head-commit 真的会拦:

$ gh pr merge 31 --squash --match-head-commit 732bdaa...
GraphQL: Head branch was modified. Review and try the merge again. (mergePullRequest)

然后重新打上 queue。这里又碰到一个小问题:打完标签马上运行,gh pr list --label queue 返回的是空列表,等几秒再跑才看到 #31。所以合并员要放在循环里每隔一两分钟跑一次,不要指望打标签那一刻就能触发。第三次运行:

MERGED  #31  tree b4a7ff9 == tested

三个 PR 全部合入,每一次合并后主干的树都和合并员测过的树完全一致,最终主干 3 passed。2 个 PR 的那一轮(一个退回,一个合并)总共用了 29 秒,测试本身只要 0.06 秒,其余时间几乎全是 gh 调用 GitHub API 的往返。可以按每个 PR 十几秒的固定开销,再加上你自己测试套件的时长来估算吞吐。

还有一个结果要说。本地实验里我把合并顺序换成 2、3、1,被退回的就变成了 1 号机的重构 PR:

MERGED   m2/export-cmd -> main 263132a
BOUNCED  m3/discount-cmd: conflict in CHANGELOG.md shop/registry.py
BOUNCED  m1/rename-total: green alone, red on main 263132a:

合并员只能判断“谁后到、谁就和主干合不上”,判断不了是谁的错。这在实践中影响很大:改名、删函数这种破坏性的重构如果排在后面,它会被其他功能 PR 一次又一次地退回,而它每次都得重新扫一遍全仓库的调用点。解决办法不在合并员身上,在改法上,下一节会讲。

从结构上减少冲突

合并员能保证主干不红,但它只是兜底,冲突本身没有减少。每退回一次,开发机就要重新跑一轮,这都是成本。我在本地比较了几种常见的做法,结果如下:

本地实测:照常修改时三个场景全部冲突或主干红;merge=union 对 CHANGELOG 有效,但让 Python 注册表出现 SyntaxError、JSON 列表缺逗号;碎片文件加自注册加保留旧名后全部干净且正确;合并列车把问题退回开发机

一个常见的建议是在 .gitattributes 里给这些文件设 merge=union,让 git 遇到冲突时把两边的行都留下。对 CHANGELOG 这种“每行独立、顺序无所谓”的文件,它确实有效。可我把它用到 Python 注册表上,git 不报冲突,合出来的文件是这样的:

from shop.commands import total, export

COMMANDS = {
    "total": total.run,
    "export": export.run,
from shop.commands import total, discount

COMMANDS = {
    "total": total.run,
    "discount": discount.run,
}

测试直接报 SyntaxError: '{' was never closed。JSON 列表也一样:两边各加一项,union 合并后两项之间缺了一个逗号,json.tool 报 Expecting ',' delimiter。它把一个会吵的问题变成了不出声的问题,这比冲突更糟。我的建议是 merge=union 只用在纯文本清单上,任何有语法的文件都不要用。

真正有效的是三个结构上的改动。

第一,CHANGELOG 改成碎片文件。每个 PR 新建 changes/<分支名>.md,发版脚本再把它们拼起来。两个 PR 新增两个不同的文件,不可能冲突。

第二,注册表改成自注册。每个命令模块自己声明 NAME,注册表用 pkgutil.iter_modules 扫描目录自动收集。加命令就是加一个文件,不用再去改一个共享的字典。路由、插件列表、功能开关、文档索引都可以照这个思路改。opencode 那张插件表要是改成“一个插件一个文件”,那 12 对冲突里的 10 对就不会出现。

第三,改名用“先扩后缩”(expand/contract)。1 号机的 PR 加上新名字 order_total,同时保留 calc_total 作为一行转调的别名;等主干上 git grep calc_total 搜不到别的调用点了,再开一个小 PR 删掉它。这样 2 号机按旧名字写的代码照样能跑,重构 PR 也不会因为排在后面被反复退回。

三条改完以后,我用同样三个 PR、同样的默认合并方式再跑了一遍。三个 PR 都干净合入,每次合并后主干都是 3 passed,注册表里自动出现了 discount、export、total 三个命令。注意这次没有用合并员,说明冲突是从源头上消失的,不是被拦下来的。

冲突雷达:提前告诉开发机

还有一个便宜的预警办法:对所有打开的 PR 两两跑 git merge-tree,有冲突就提前告诉相关的开发机。opencode 那组数据就是用这个脚本跑出来的。

conflict-radar.sh

#!/usr/bin/env bash
# conflict-radar.sh — which open PRs will conflict with each other once one merges?
# Dry-runs every pair with `git merge-tree` (no checkout, ~ms per pair).
#   ./conflict-radar.sh --gh [BASE]        # all open PRs of the current GitHub repo
#   ./conflict-radar.sh ref1 ref2 ...      # local branches
set -uo pipefail
if [ "${1:-}" = --gh ]; then
  BASE=${2:-main}; refs=()
  for pr in $(gh pr list --base "$BASE" --state open --json number --jq '.[].number'); do
    git fetch -q origin "+refs/pull/$pr/head:refs/radar/$pr" && refs+=("refs/radar/$pr")
  done
else refs=("$@"); fi
for ((i = 0; i < ${#refs[@]}; i++)); do
  for ((j = i + 1; j < ${#refs[@]}; j++)); do
    a=${refs[i]} b=${refs[j]}
    if ! out=$(git merge-tree --write-tree --name-only --no-messages "$a" "$b" 2>/dev/null); then
      echo "CONFLICT  ${a##*/} x ${b##*/}: $(echo "$out" | tail -n +2 | sort -u | tr '\n' ' ')"
    fi
  done
done

在本地三个分支上,它报出 export-cmd x discount-cmd: CHANGELOG.md shop/registry.py。但它没有报 1 号机和 2 号机这一对,因为这两个之间没有文本冲突,只有语义冲突。所以雷达只能用来提醒,不能当闸门用,闸门还是合并员。

它也有成本。60 个 PR 两两组合是 1770 对,在一个用 --filter=blob:none 做的部分克隆上跑了 7 分 26 秒,大部分时间花在按需下载对象上。你自己的仓库在本地有完整对象,PR 数也少得多,这个开销基本可以忽略。

给开发机代理的规则

上面这些要落到代理每天都会读的规则文件里,否则它们下一次还是会往注册表末尾加一行。我放进 AGENTS.md 的是这几条:

AGENTS.parallel.md

## Parallel PR rules (dev machines 1-3)

- One PR = one intent. Do not bundle a rename/move/delete of shared code with feature work.
- Renaming or removing anything other files use: expand/contract. PR 1 adds the new name
  and keeps the old one as a thin alias; a later PR removes the alias once
  `git grep <old-name>` is empty on main.
- Never append to a shared list by hand. Changelog: create `changes/<branch>.md`.
  Commands/routes/plugins: add a new module that declares itself; registries discover.
- Lockfiles: only a PR whose task is "change dependencies" may touch them. If you need a
  new dependency for a feature, open that dependency PR first and stop.
- Before opening a PR, run `kit/conflict-radar.sh --gh main` and list any PR you conflict
  with in your PR description.
- Do not merge. Machine 7 merges PRs labelled `queue`. If it bounces your PR
  (`train:conflict` / `train:red`), rebase on main, fix, push, re-add `queue`.
  After `train:human`, stop and wait for a person.

锁文件那一条值得单独说一下。bun.lock、package-lock.json 这类文件冲突了,不能手工合,只能重新生成;两个 PR 各自加依赖,合并后生成的锁文件和任何一边都不一样。最省事的规则是:依赖变更单独开 PR,排在功能 PR 前面。

代价和不适合的情况

合并员是串行的。 每个 PR 的耗时大约是测试时长加上十几秒 API 开销。三个人一天二三十个 PR、测试五分钟以内,一台 7 号机绰绰有余。要是测试要二十分钟以上、PR 又多,队列就会越排越长。这时可以一次取两个 PR 合在一起测,通过就一起合,不通过再拆开分别测;或者考虑把仓库搬进组织并公开,用 GitHub 自带的合并队列,它能并行地预先构建好几组组合。

它的可靠程度取决于你的测试。 合并员只能发现测试能发现的问题。如果两个 PR 一个改了配置文件的结构,另一个按旧结构读,测试又没覆盖这个读取路径,合并员照样会放行。上一篇里的 6 号机 Windows 闸门也一样:如果你要求它必须通过,合并员在合并前也应该检查 PR 上的这项检查是不是绿的。

个人私有仓库没法强制。 没有分支保护,任何人都还能直接点合并按钮。合并员合并后会比对主干的树和测过的树,不一致就打印 WARNING,这只能事后提醒。要真正强制,只能升级套餐或者把仓库搬进组织。在那之前,靠团队约定加上事后告警。

7 号机是单点。 它关机,合并就停了。这种失败是安全的,主干不会因此变坏,只是 PR 会堆着。真要紧急合并,可以在任意一台机器上手工跑一次 train-step.sh,通过了再点合并。

如果你们只有一台开发机,或者每天只有两三个 PR,就不用搭这套。 那种情况下,合并前在本地 rebase 一下、跑一遍测试就够了。合并员解决的是并行带来的问题,没有并行就没有这个问题。

明天就能做的五件事

  1. 在你们的仓库上跑一次 conflict-radar.sh --gh main,再用 git log --name-only 统计最近一百个提交里被改得最多的文件。排在前面的通常就是注册表、CHANGELOG 和锁文件。
  2. 挑冲突最多的那个文件,改成碎片文件或者自注册。这一步通常半天就能完成,减少的冲突最多。
  3. 在 7 号机上克隆仓库,创建 queue、train:conflict、train:red、train:human 四个标签,用 BASE=main TEST_CMD="你们 CI 的测试命令" ./merge-train.sh 先手工跑一次。
  4. 故意开一个“单独绿、合起来红”的 PR,确认它被退回、评论里贴了测试输出。我自己第一次就是在这条路上翻的车。
  5. 把 AGENTS.parallel.md 贴进代理的规则文件,告诉团队里的三个人:从今天起不再手动点合并。

脚本、规则和全部实验脚本都打包在这里:merge-train-kit.tar.gz。

哪些跑过,哪些没跑过

先说明一点:三台开发机的“代理改动”是我照代理常见的改法手写的三个分支,不是让代理现场生成的;要测的是合并环节,改动本身是谁写的不影响结论。

在真实 GitHub 上跑过的:merge-train.sh 在 shuizhengqi1/pages 的临时分支上处理了三个真实 PR(#30–#32),包括测试红的退回、评论、--match-head-commit 拒绝旧提交、重新入队后合并,以及合并后树哈希的比对。实验结束后,临时分支和四个标签都删了,三个 PR 作为已合并记录留在仓库里。冲突退回(train:conflict)这条分支只在本地跑过,GitHub 上用的是同一个 bounce 函数,但没有专门触发。

在本地跑过的:默认合并和合并列车的对比、换顺序后的退回、merge=union 在 CHANGELOG、Python 注册表和 JSON 列表上的结果、碎片文件加自注册加保留旧名的修复版,以及冲突雷达。opencode 的数据是对它当时打开的 PR 做的只读抓取和本地试合并。

没跑过的:ci-merge-group.yml 是给能用 GitHub 合并队列的仓库准备的,我手上没有这样的仓库,所以没有实际跑过。批量合并(一次取两个 PR)只是一个思路,脚本里没有实现。rebase 风暴的 21 次和 6 次是计算出来的,不是测出来的。

Same three-person team, same seven computers. Agents on machines 1, 2 and 3 branch off main and work; machines 4 and 5 review; machine 6 does Windows validation; machine 7 has never had a fixed job.

Tuesday afternoon, all three dev machines open PRs at about the same time. Machine 1 does a refactor: it renames calc_total to order_total, adds a tax-rate parameter, and updates every call site in the repo. Machine 2 adds an export command that calls calc_total, because that is what the function was called on main when it branched. Machine 3 adds a discount command and, like machine 2, appends one line to the command registry and one to the CHANGELOG. All three PRs have green CI, and the review machines pass all three. On GitHub, each one shows MERGEABLE / CLEAN with a green merge button.

Merge machine 1's PR first: main stays green. Merge machine 2's: no conflict, and main goes red: ImportError: cannot import name 'calc_total', so the whole test module fails to import. Machine 3's PR now has conflicts in CHANGELOG.md and shop/registry.py, the two files where everyone appends a line at the end.

I didn't make this scenario up. As you'll see below, I ran it with three real PRs in a real GitHub repository. My claim: once three machines ship PRs in parallel, "whoever finishes review clicks merge" stops working. One machine should do the merging. It takes PRs one at a time, runs the tests on "current main plus this PR", and bounces anything that fails back to the dev machine. Machine 7, which has been sitting idle, gets that job. You also need to restructure the few files where everyone appends a line at the end. If you don't, the merge clerk will spend most of its time bouncing conflicts.

There are four experiments below: a simulation in local git repos, the merge train running on real GitHub PRs, a pairwise dry merge of the 60 PRs currently open in a busy open-source repo, and a comparison of common ways to avoid conflicts. All scripts are inline, and the bundle is linked at the end.

Why every PR was green and main still went red

Start with what CI actually tested. A GitHub Actions pull_request run checks out refs/pull/N/merge: the PR merged with main as it was when the run was triggered (docs). So a green check means "fine combined with main from ten minutes ago." It has never seen anything that merged after that.

MERGEABLE on the PR page means only one thing: git's three-way merge found no textual conflict. Nothing gets compiled and no tests run. Machine 1 changed pricing.py and total.py, and machine 2 added export.py. No line was touched by both, so git says the merge is clean. But machine 2's new from shop.pricing import calc_total depends on a name machine 1 just deleted. That is a semantic conflict: the text merges and the meaning doesn't. The only way to find one is to run the tests on the merged tree.

Human teams hit this too, just rarely. One person opens a PR or two a day, the PRs are spread out, and people roughly know what everyone else is touching. Agents push three things to the extreme at once. Parallelism: three machines branch from the same main commit and work blind to each other. Breadth: an agent doing a refactor updates every call site in the repo in one go, and an agent adding a feature also updates the registry, the CHANGELOG, the dependency manifest and the docs index while it's there. Bunching: agents are fast, so PRs arrive in clumps within an hour, and the review machines release them in clumps too. Three PRs start from the same commit and merge within the same hour, and none of their CI runs ever saw the others.

Experiment 1: a local simulation, merged the default way

I first reproduced the scenario in a local git repo: a tiny Python order CLI with three branches, one per dev machine. The merge step mimics GitHub's squash merge. Each PR runs its tests on its own branch, then gets squashed onto whatever main is at that moment, and nothing is re-tested afterwards. That is exactly what a repo without branch protection does every day. Raw output:

== PR m1/rename-total: CI on its own branch
3 passed in 0.01s
== squash-merge m1/rename-total onto main ab1c305
   merged clean -> 6a8135e
== tests on main after merging m1/rename-total
3 passed in 0.01s
== PR m2/export-cmd: CI on its own branch
3 passed in 0.01s
== squash-merge m2/export-cmd onto main 6a8135e
   merged clean -> 862b94c
== tests on main after merging m2/export-cmd
E   ImportError: cannot import name 'calc_total' from 'shop.pricing'
== PR m3/discount-cmd: CI on its own branch
3 passed in 0.01s
== squash-merge m3/discount-cmd onto main 862b94c
   CONFLICT (content): Merge conflict in CHANGELOG.md
   CONFLICT (content): Merge conflict in shop/registry.py

Both kinds of failure showed up. The semantic conflict turns main red, and it does so quietly: the merge button was green, and nobody runs the tests right after merging. The textual conflict is louder but easier to handle, since at least it stops you. The first kind is the expensive one. Every agent that branches from a red main starts on a broken base, so the next PRs from machines 1, 2 and 3 start failing tests for no visible reason, and the agents try to "fix" breakage they didn't cause.

Experiment 2: how many PRs conflict with each other in a real repo right now

A local simulation shows the mechanism, not how often it happens. I took a repo with a lot of PR traffic, anomalyco/opencode. At 18:45 Taipei time on 2026-10-22 I fetched its 60 newest open PRs and dry-merged every pair with git merge-tree. That command needs no checkout: it computes the merge inside the object store and exits non-zero on a conflict.

60 open PRs in opencode: 14 already conflict with main; among the other 46, 12 pairs conflict with each other, 10 of them on one file, the ecosystem.mdx plugin table; files modified by 3+ open PRs include ecosystem.mdx, bun.lock, i18n/en.ts and package.json

Of the 60 PRs, 14 already conflicted with main, so their authors have to rebase first. The other 46 could each merge cleanly right now, but 12 pairs among them conflict: as soon as one PR in a pair lands, the other one becomes a conflict. Ten of those 12 pairs are on a single file, packages/web/src/content/docs/ecosystem.mdx. I opened it: it's a Markdown table of community plugins, and every PR appends a row at the bottom. According to the GitHub API, the other files being modified by three or more open PRs at once are bun.lock, i18n/en.ts and package.json. They're all the same kind of file: registries, lockfiles and string tables that everyone has to add something to.

This is a community project and its PRs aren't necessarily written by agents, so don't apply these numbers directly to your team. What they show is where conflicts land. At least in this repo, conflicting PRs rarely collide in complicated business logic; almost all of them collide on one or two shared-list files. For a team with three dev machines that's good news, because a handful of files can be fixed once.

Why not just use GitHub's merge queue

GitHub already has a feature for this, called merge queue. A queued PR is combined with the PRs ahead of it on a temporary branch, the required checks run there, and the PR merges only if they pass. It works on the same principle as the setup in this post. The question is whether you can turn it on. According to the docs, it's available only on public repos owned by an organization and on private repos of organizations on Enterprise Cloud. Plenty of three-to-five-person teams keep a private repo under someone's personal account. My own repo can't even read the rulesets API:

$ gh api repos/shuizhengqi1/pages/rulesets
gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)

A step down from that, branch protection has a "require branches to be up to date before merging" setting. It does stop semantic conflicts, because every PR has to rebase onto the latest main and re-run CI before it can merge. But it causes rebase storms: each merge makes every other waiting PR stale, so they all have to rebase and re-run CI again. Say six PRs pass review at once. Landing all of them takes 6+5+4+3+2+1 = 21 CI runs; testing them one at a time in a queue takes 6. That's arithmetic, not a measurement, but the logic is simple. Worse, the rebases usually fall to the agents on the dev machines. Each one eats into the agent's context, and the agent may "improve" a few lines while it's at it.

So for a private repo under a personal account, the practical move is to build a minimal merge queue yourself. It never touches the PR branches and never asks an agent to rebase. It just builds "main + PR" as a candidate commit on the clerk's own machine and tests that.

The setup: machine 7 as the merge clerk

The core is a script that never touches a working tree. It uses git merge-tree --write-tree (git 2.38+) to compute the tree that a squash merge of the PR onto main would produce, wraps that tree in a candidate commit with git commit-tree, checks the commit out into a temporary worktree, and runs the tests there. It exits 10 on a conflict and 20 if the tests fail; on success it prints the candidate commit and tree hashes:

train-step.sh

#!/usr/bin/env bash
# train-step.sh <base-ref> <head-ref>
# Test what main WOULD look like after squash-merging <head-ref>, without touching
# any checkout. Needs git >= 2.38 (merge-tree --write-tree).
#   exit 0  -> prints "<candidate-commit> <tree>"; tests passed on the combination
#   exit 10 -> textual conflict; prints the conflicted paths
#   exit 20 -> merges clean but TEST_CMD fails on the combination; log in $TRAIN_LOG
set -uo pipefail
base=$(git rev-parse "$1") head=$(git rev-parse "$2")
TEST_CMD=${TEST_CMD:-"python3 -m pytest -q"}
TRAIN_LOG=${TRAIN_LOG:-$(mktemp)}

if ! out=$(git merge-tree --write-tree --name-only --no-messages "$base" "$head"); then
  echo "$out" | tail -n +2        # line 1 is the (conflicted) tree id
  exit 10
fi
tree=$(echo "$out" | head -1)
cand=$(git commit-tree "$tree" -p "$base" -m "train candidate: $2")

wt=$(mktemp -d)
git worktree add -q --detach "$wt" "$cand"
( cd "$wt" && eval "$TEST_CMD" ) >"$TRAIN_LOG" 2>&1; rc=$?
git worktree remove --force "$wt"
[ $rc -eq 0 ] || exit 20
echo "$cand $tree"

A wrapper runs it on every GitHub PR labelled queue. The review machines can add that label when they approve, which plugs into the label flow from the post two days ago. The clerk does four things. It takes the queue in PR-number order. On a conflict or a red test run, it removes queue, adds train:conflict or train:red, and posts the conflicting files or the test output as a comment. On green, it merges with gh pr merge --squash --match-head-commit, which guarantees that the commit it tested is the commit that merges; if the dev machine pushed something in the meantime, the merge is refused. After merging, it checks that main's tree matches the tree it tested. If the same PR gets bounced three times, the clerk labels it train:human and stops the agent-vs-clerk ping-pong.

merge-train.sh

#!/usr/bin/env bash
# merge-train.sh — run on machine 7 inside a clone of the repo. Merges PRs labelled
# `queue` one at a time, each tested on top of the CURRENT base branch.
# Works on a personal private repo (no merge queue, no branch protection needed).
#   BASE=main TEST_CMD="npm test" ./merge-train.sh          # one pass
#   while sleep 120; do ./merge-train.sh; done              # machine 7 loop
set -uo pipefail
BASE=${BASE:-main}
MAX_BOUNCES=${MAX_BOUNCES:-3}
export TEST_CMD=${TEST_CMD:-"python3 -m pytest -q"}
STEP="$(cd "$(dirname "$0")" && pwd)/train-step.sh"
MARK='<!-- merge-train -->'

bounce() {  # bounce <pr> <label> <message-file>
  local pr=$1 label=$2 msg=$3 n
  n=$(gh pr view "$pr" --json comments --jq "[.comments[].body|select(contains(\"$MARK\"))]|length")
  if [ "$n" -ge "$MAX_BOUNCES" ]; then label=train:human; fi   # stop the agent ping-pong
  gh pr edit "$pr" --remove-label queue --add-label "$label" >/dev/null
  { echo "$MARK"; cat "$msg"; } | gh pr comment "$pr" --body-file - >/dev/null
  echo "BOUNCED #$pr -> $label"
}

for pr in $(gh pr list --base "$BASE" --label queue --state open --json number --jq 'sort_by(.number)|.[].number'); do
  git fetch -q origin "$BASE" "+refs/pull/$pr/head:refs/train/pr-$pr" || continue
  base_sha=$(git rev-parse "origin/$BASE"); head_sha=$(git rev-parse "refs/train/pr-$pr")
  export TRAIN_LOG=$(mktemp); msg=$(mktemp)
  res=$("$STEP" "$base_sha" "$head_sha"); rc=$?
  case $rc in
  10) { echo "**Conflicts with \`$BASE\` @ ${base_sha:0:7}** in:"; echo "$res" | sed 's/^/- `/; s/$/`/'
        echo; echo "Rebase on \`$BASE\`, keep both sides' intent, push; then re-add \`queue\`."; } >"$msg"
      bounce "$pr" train:conflict "$msg"; continue;;
  20) { echo "**Green on its own branch, red on top of \`$BASE\` @ ${base_sha:0:7}.** Tail of \`$TEST_CMD\`:"
        echo '```'; tail -n 25 "$TRAIN_LOG"; echo '```'
        echo "Something merged since you branched changes what this PR relies on. Fix on top of \`$BASE\`, then re-add \`queue\`."; } >"$msg"
      bounce "$pr" train:red "$msg"; continue;;
  0)  tested_tree=${res#* };;
  *)  echo "train-step failed for #$pr (rc=$rc)"; continue;;
  esac

  # Compare-and-swap: only merge if nobody moved BASE or pushed to the PR meanwhile.
  [ "$(git ls-remote origin "refs/heads/$BASE" | cut -f1)" = "$base_sha" ] || { echo "#$pr: $BASE moved, retry next pass"; continue; }
  if ! gh pr merge "$pr" --squash --match-head-commit "$head_sha" >/dev/null; then
    echo "#$pr: merge refused (new push?), retry next pass"; continue
  fi
  git fetch -q origin "$BASE"
  merged_tree=$(git rev-parse "origin/$BASE^{tree}")
  if [ "$merged_tree" = "$tested_tree" ]; then echo "MERGED  #$pr  tree ${tested_tree:0:7} == tested"
  else echo "WARNING #$pr: $BASE tree ${merged_tree:0:7} != tested ${tested_tree:0:7} (someone merged by hand?)"; fi
done

In my own repo I used a throwaway branch as "main", pushed the same three branches as in experiment 1, opened three real PRs (#30, #31, #32), labelled them queue, and ran the script from the box. Before the run, GitHub showed all three as MERGEABLE / CLEAN.

Comparison: with the default merge, #31 merges clean but main hits ImportError and #32 conflicts; with the merge train, #30 merges, #31 is bounced with train:red, #32 merges, and #31 merges on a second pass after machine 2 fixes it; main stays green throughout

The first run broke, and the bug was in my own script. #30 merged fine, then the script died when it reached #31:

MERGED  #30  tree 97e866c == tested
../../kit/merge-train.sh: line 31: TEST_CMD: unbound variable

The red-test branch writes TEST_CMD into its comment, but I had only given that variable a default inside train-step.sh. With set -u on, the undefined variable killed the script. #31 kept its queue label without being bounced, and nothing after it was processed. Because the script is idempotent, adding the default and re-running picked up where it stopped. The lesson: before the clerk goes live, deliberately feed it a PR that will go red, and walk the bounce path for real. If you've only tested the happy path, you haven't tested the clerk. The second run, after the fix:

BOUNCED #31 -> train:red
MERGED  #32  tree 7c0afb6 == tested

Here's how the comment on #31 starts. The full test output is pasted in as well, so machine 2's agent knows exactly where to look:

**Green on its own branch, red on top of `lab/train-2026-10-22` @ 6640300.**
...
shop/commands/export.py:2: in <module>
    from shop.pricing import calc_total
E   ImportError: cannot import name 'calc_total' from 'shop.pricing'

Next I played machine 2's agent fixing the PR. I rebased onto the new main, where #32 had already landed, so CHANGELOG.md and registry.py conflicted; I kept both sides' lines. I switched calc_total to order_total, ran the tests locally, and force-pushed. Before pushing, I tried merging with the old head SHA to confirm --match-head-commit really blocks it:

$ gh pr merge 31 --squash --match-head-commit 732bdaa...
GraphQL: Head branch was modified. Review and try the merge again. (mergePullRequest)

Then I re-added queue and hit another small snag. Running the clerk right after adding the label, gh pr list --label queue came back empty; a few seconds later #31 showed up. So run the clerk in a loop every minute or two, and don't count on it reacting the instant a label is added. Third run:

MERGED  #31  tree b4a7ff9 == tested

All three PRs landed. After every merge, main's tree was identical to the tree the clerk had tested, and the final main reported 3 passed. The two-PR pass (one bounce, one merge) took 29 seconds in total. The tests themselves took 0.06 seconds; almost all the rest was gh round trips to the GitHub API. To estimate throughput, budget a fixed cost of roughly ten-plus seconds per PR plus the length of your own test suite.

One more result. In a local run I changed the merge order to 2, 3, 1, and this time the PR that got bounced was machine 1's refactor:

MERGED   m2/export-cmd -> main 263132a
BOUNCED  m3/discount-cmd: conflict in CHANGELOG.md shop/registry.py
BOUNCED  m1/rename-total: green alone, red on main 263132a:

All the clerk can tell is who arrived later and no longer fits on main. It can't tell whose fault the break is. That matters a lot in practice: a destructive refactor such as a rename or a function deletion that lands late in the queue gets bounced by feature PRs again and again, and each time it has to rescan the whole repo for call sites. The fix isn't in the clerk; it's in how you make the change, which the next section covers.

Removing conflicts at the source

The clerk keeps main green, but it's a backstop: it doesn't reduce the number of conflicts. Every bounce costs the dev machine another round of work. I compared several common approaches locally:

Measured locally: editing as usual conflicts or turns main red in every case; merge=union works for the CHANGELOG but produces a SyntaxError in the Python registry and a JSON list missing a comma; fragments + self-registration + an alias are clean and correct everywhere; the merge train bounces problems back to the dev machine

A common suggestion is to set merge=union on these files in .gitattributes, which makes git keep both sides' lines instead of reporting a conflict. For a CHANGELOG, where every line stands alone and order doesn't matter, it works. When I applied it to the Python registry, git reported no conflict and produced this:

from shop.commands import total, export

COMMANDS = {
    "total": total.run,
    "export": export.run,
from shop.commands import total, discount

COMMANDS = {
    "total": total.run,
    "discount": discount.run,
}

The tests failed with SyntaxError: '{' was never closed. A JSON list fared no better: each side added one entry, and the union result was missing a comma between them, so json.tool reported Expecting ',' delimiter. Union turns a loud problem into a silent one, which is worse than a conflict. My advice: use merge=union only on plain-text lists, and never on a file that has syntax.

What actually works is three structural changes.

First, split the CHANGELOG into fragments. Each PR creates changes/<branch>.md, and the release script concatenates them. Two PRs that create two different files can't conflict.

Second, make the registry self-registering. Each command module declares its own NAME, and the registry scans the directory with pkgutil.iter_modules to collect them. Adding a command means adding a file, not editing a shared dict. The same idea works for routes, plugin lists, feature flags and docs indexes. If opencode's plugin table were one file per plugin, 10 of those 12 conflicting pairs would never happen.

Third, do renames as expand/contract. Machine 1's PR adds order_total and keeps calc_total as a one-line forwarding alias. Once git grep calc_total finds no other callers on main, a small follow-up PR removes the alias. Code machine 2 wrote against the old name keeps working, and the refactor PR stops getting bounced just because it reached the queue late.

With all three changes in place, I re-ran the same three PRs with the same default merge. All three landed cleanly, main reported 3 passed after each merge, and the registry picked up discount, export and total automatically. Note that no clerk was involved this time: the conflicts disappeared at the source instead of being caught.

Conflict radar: warn the dev machines early

One more cheap early warning: dry-merge every pair of open PRs with git merge-tree and tell the affected dev machines about any conflict before it bites. The opencode numbers above came from this script.

conflict-radar.sh

#!/usr/bin/env bash
# conflict-radar.sh — which open PRs will conflict with each other once one merges?
# Dry-runs every pair with `git merge-tree` (no checkout, ~ms per pair).
#   ./conflict-radar.sh --gh [BASE]        # all open PRs of the current GitHub repo
#   ./conflict-radar.sh ref1 ref2 ...      # local branches
set -uo pipefail
if [ "${1:-}" = --gh ]; then
  BASE=${2:-main}; refs=()
  for pr in $(gh pr list --base "$BASE" --state open --json number --jq '.[].number'); do
    git fetch -q origin "+refs/pull/$pr/head:refs/radar/$pr" && refs+=("refs/radar/$pr")
  done
else refs=("$@"); fi
for ((i = 0; i < ${#refs[@]}; i++)); do
  for ((j = i + 1; j < ${#refs[@]}; j++)); do
    a=${refs[i]} b=${refs[j]}
    if ! out=$(git merge-tree --write-tree --name-only --no-messages "$a" "$b" 2>/dev/null); then
      echo "CONFLICT  ${a##*/} x ${b##*/}: $(echo "$out" | tail -n +2 | sort -u | tr '\n' ' ')"
    fi
  done
done

On the three local branches it reports export-cmd x discount-cmd: CHANGELOG.md shop/registry.py. It does not report the machine 1 / machine 2 pair, because that pair has no textual conflict, only a semantic one. So the radar is a warning, not a gate. The gate is still the clerk.

It isn't free either. Sixty PRs make 1,770 pairs, which took 7 minutes 26 seconds on a --filter=blob:none partial clone, mostly spent downloading objects on demand. Your own repo has a full local object store and far fewer PRs, so the cost there is negligible.

Rules for the dev machines' agents

None of this sticks unless it goes into the rules file the agents read every day; otherwise they'll append to the registry again next time. This is what I put in AGENTS.md:

AGENTS.parallel.md

## Parallel PR rules (dev machines 1-3)

- One PR = one intent. Do not bundle a rename/move/delete of shared code with feature work.
- Renaming or removing anything other files use: expand/contract. PR 1 adds the new name
  and keeps the old one as a thin alias; a later PR removes the alias once
  `git grep <old-name>` is empty on main.
- Never append to a shared list by hand. Changelog: create `changes/<branch>.md`.
  Commands/routes/plugins: add a new module that declares itself; registries discover.
- Lockfiles: only a PR whose task is "change dependencies" may touch them. If you need a
  new dependency for a feature, open that dependency PR first and stop.
- Before opening a PR, run `kit/conflict-radar.sh --gh main` and list any PR you conflict
  with in your PR description.
- Do not merge. Machine 7 merges PRs labelled `queue`. If it bounces your PR
  (`train:conflict` / `train:red`), rebase on main, fix, push, re-add `queue`.
  After `train:human`, stop and wait for a person.

The lockfile rule deserves its own note. When bun.lock or package-lock.json conflicts, you can't merge it by hand; you can only regenerate it. If two PRs each add a dependency, the regenerated lockfile matches neither side. The simplest rule: dependency changes go in their own PR, ahead of the feature PRs that need them.

Costs, and when this doesn't fit

The clerk is serial. Each PR costs roughly its test time plus ten-plus seconds of API overhead. For three people shipping twenty or thirty PRs a day with a test suite under five minutes, one machine 7 is plenty. If your tests take twenty minutes or more and PRs are plentiful, the queue will keep growing. At that point, test two PRs together and merge both if they pass, splitting them apart only on failure. Or move the repo into an organization, make it public, and use GitHub's merge queue, which can pre-build several combinations in parallel.

It's only as good as your tests. The clerk can only catch what the tests catch. If one PR changes the shape of a config file and another reads the old shape along a path no test covers, the clerk lets them through. The same goes for the machine 6 Windows gate from the previous post: if it must pass, the clerk should also check that the PR's gate check is green before merging.

A private personal repo can't enforce any of this. Without branch protection, anyone can still click the merge button. After each merge the clerk compares main's tree with the tree it tested and prints a WARNING if they differ, but that's an alert after the fact. Real enforcement requires a plan upgrade or moving the repo into an organization. Until then, it rests on a team agreement plus that after-the-fact alert.

Machine 7 is a single point of failure. If it's off, merging stops. That failure is safe: main can't break because of it, the PRs just pile up. For an emergency merge, run train-step.sh by hand on any machine and click merge if it passes.

If you have one dev machine, or two or three PRs a day, skip all this. In that situation, rebasing locally and running the tests before you merge is enough. The clerk solves a problem that comes from parallelism; without parallelism you don't have the problem.

Five things to do tomorrow

  1. Run conflict-radar.sh --gh main on your repo, and count the most-changed files over the last hundred commits with git log --name-only. The top of that list is usually registries, CHANGELOGs and lockfiles.
  2. Take the file with the most conflicts and convert it to fragments or self-registration. That's usually half a day of work, and it removes more conflicts than anything else.
  3. Clone the repo on machine 7, create the four labels (queue, train:conflict, train:red, train:human), and do one manual run of BASE=main TEST_CMD="your CI test command" ./merge-train.sh.
  4. Deliberately open a PR that is green alone and red when combined, and confirm the clerk bounces it with the test output in the comment. That's exactly where my own first run fell over.
  5. Paste AGENTS.parallel.md into your agents' rules file, and tell the three people on the team that from today nobody clicks merge by hand.

Scripts, rules and all the experiment scripts are bundled here: merge-train-kit.tar.gz.

What I actually ran, and what I didn't

One note first: the three "agent" changes are branches I wrote by hand to mimic how agents typically make such changes; no agent generated them live. What's under test is the merge step, and who wrote the diffs doesn't change the result.

Run on real GitHub: merge-train.sh handled three real PRs (#30–#32) on throwaway branches in shuizhengqi1/pages. That covered the red-test bounce and its comment, --match-head-commit refusing a stale SHA, re-queue and merge, and the tree-hash check after each merge. Afterwards I deleted the throwaway branches and the four labels; the three PRs remain in the repo as merged records. The conflict bounce (train:conflict) ran only locally. On GitHub it goes through the same bounce function, but I didn't trigger it there specifically.

Run locally: the default-merge vs. merge-train comparison, the reordered run that bounced the refactor, merge=union on a CHANGELOG, a Python registry and a JSON list, the fixed layout with fragments + self-registration + alias, and the conflict radar. The opencode data comes from a read-only fetch of the PRs open at that moment plus local dry merges.

Not run: ci-merge-group.yml is for repos that can use GitHub's merge queue; I don't have one, so it's untested. Batching two PRs at a time is only an idea and isn't implemented in the script. The 21 vs. 6 rebase-storm figures are calculated, not measured.