AI

三个人七台电脑:状态写进 GitHub,审查机自己领活(中英切换)

三个人,七台电脑。1、2、3 号是开发机,每台上同时开着两三个 Claude Code、Codex 或 Cursor 会话;4、5 号负责审查;6 号是唯一一台 Windows,用来验证;7 号还没定用途。

一个普通的下午是这样坏掉的。两点前后,三台开发机的代理差不多同时干完活,十几分钟里开了八个 PR。4 号机手上压着六个,5 号机在审一个改了鉴权的大 PR,一个小时没动。1 号机的代理等不到审查结论,又开了一个新分支,改的正是 2 号机刚改过的那个文件。周一合进去的一个 PR,周三才在 6 号机上发现 Windows 下路径拼错了,因为 6 号机只有在有人想起来的时候才会跑。「这个我看过了」写在群里,二十条消息之后就找不到了。AI 审查给每个 PR 都写了一句「LGTM,结构清晰」。

我的判断:机器一多,瓶颈就从写代码变成了交接。交接要有一个所有机器都读写的地方,最省事的就是 GitHub 本身。 每个 PR 身上挂一个状态标签,审查机按 WIP 上限自己来领活,6 号机注册成 Actions 的自建 runner,跟进交给定时 workflow。人不用记状态,也不用派活,只在闸门上做判断。

下面每一段都配了能直接放进仓库的文件。YAML 过了 actionlint,脚本过了 shellcheck,状态机、跟进和认领逻辑都在本地用模拟的 GitHub 跑过测试。完整的一套打包在文末,哪些没测、为什么没测,也写在文末。

7 台机器的分工:1–3 号开发机开 PR,4、5 号审查站自己来领,6 号是 Windows 自建 runner,7 号机动;定时任务每 30 分钟跟进,机器之间不直接对话

坏的是交接,产能并不缺

AI 加速的是开发这一头。一个代理二三十分钟就能交一个 PR,三台机器各开两三个会话,PR 是一阵一阵涌进来的。审查那一头还是人的速度:一个有判断量的 PR,人要看十几分钟到一两个小时。

排队论里有条很朴素的关系:在制品数量 = 吞吐 × 停留时间。审查的吞吐被人卡死以后,PR 来得越快,队列就越长,每个 PR 在队里等的时间也越长。这件事调度改不了,能改的只有三样:让队列看得见,让空闲的审查机先拿到活,让队列满了的时候上游停下来。

上面那个下午的每一种坏法,都是这三样缺了一样。PR 堆在 4 号机,是因为活是「推」过去的,5 号机空了也拿不到。1 号机开新分支撞上冲突,是因为没人告诉它队列已经满了,该回头处理被打回的 PR。Windows 问题发现得晚,是因为验证靠人记得。没人跟进,是因为状态在人脑子里,不在系统里。

状态只放在一个地方

PR 状态机:state:dev → ready-for-review → reviewing → win-verify → ready-to-merge → 合并,以及打回、回收和失效的回退路径

规则只有一条:每个打开的 PR 身上恰好有一个 state:* 标签。 另外两类标签记录归属:开发机开 PR 时贴 from:m1,审查机认领时贴 reviewer:m4。

为什么用标签,不用 Projects 的自定义字段?标签就在 PR 的 webhook 载荷里,gh pr list --label 一条命令就能查,PR 列表上一眼就看得到,workflow 读写它只需要默认的 GITHUB_TOKEN。Projects 字段要走 GraphQL,权限也要另外配。三个人的团队,选能在终端里一行查出来的那种。

谁来推动状态?大部分转移由事件自动触发:PR 转成正式就进 ready-for-review,Request changes 就进 changes-requested,人点 Approve 就进 win-verify。最后这一步不由状态机写,由下面的 Windows 验证 workflow 自己写,因为同一个审查事件会同时叫醒两个 workflow,两边都改标签就会互相覆盖。只有两个转移需要人或机器明确表态:作者修完以后评论 /ready,审查站的 AI 发现机械问题时评论 /changes。为什么 changes-requested 状态下推新提交不自动回到审查?因为代理修一轮可能要推五次,第一次推送就回队列,审查机会拿到半成品。

批准之后又来了新提交,状态回到 reviewing,而且回到原来那台审查机手上,因为 reviewer:m4 标签没摘。上下文还在原审查者脑子里,换人重看是浪费。

.github/workflows/pr-state.yml

name: pr-state
on:
  pull_request:
    types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
  pull_request_review:
    types: [submitted]
  issue_comment:
    types: [created]

permissions:
  contents: read
  pull-requests: write
  issues: write

concurrency:
  group: pr-state-${{ github.event.pull_request.number || github.event.issue.number }}
  cancel-in-progress: false

jobs:
  transition:
    if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && startsWith(github.event.comment.body, '/'))
    runs-on: ubuntu-latest
    steps:
      # 永远从默认分支取脚本,PR 里改了状态机脚本也不会在自己身上生效。
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: .github
      - uses: actions/github-script@v9
        with:
          script: |
            const flow = require('./.github/scripts/pr-state.js');
            await flow.run({ github, context, core });

注意 checkout 的是默认分支,不是 PR 的分支。不然一个 PR 可以改掉状态机脚本,让它在自己身上生效。状态转移的核心是一个纯函数,单测就测它:

.github/scripts/pr-state.js(节选)

function decide({ eventName, action, isDraft, labels, reviewState, commentBody }) {
  const cur = currentState(labels);
  const hasClaim = claims(labels).length > 0;
  const backToReview = hasClaim ? 'state:reviewing' : 'state:ready-for-review';

  if (eventName === 'pull_request') {
    if (['opened', 'reopened', 'ready_for_review'].includes(action)) {
      return { next: isDraft ? 'state:dev' : 'state:ready-for-review', releaseClaims: false };
    }
    if (action === 'converted_to_draft') {
      return { next: 'state:dev', releaseClaims: true };
    }
    if (action === 'synchronize') {
      // 已批准或已在验 Windows 的 PR 又来了新提交:批准作废,回到同一个审查者手里。
      if (['state:win-verify', 'state:ready-to-merge'].includes(cur)) {
        return { next: backToReview, releaseClaims: false, note: '新提交让之前的批准和 Windows 验证作废,回到审查。' };
      }
      return { next: null };
    }
  }

  if (eventName === 'pull_request_review') {
    // approved 由 win-verify workflow 自己写 state:win-verify。同一个事件只让一个 workflow 改标签,免得两边竞争。
    if (reviewState === 'approved') return { next: null };
    if (reviewState === 'changes_requested') return { next: 'state:changes-requested', releaseClaims: false };
    return { next: null };
  }

  if (eventName === 'issue_comment') {
    const cmd = (commentBody || '').trim().split(/\s+/)[0];
    if (cmd === '/ready' && cur === 'state:changes-requested') return { next: backToReview, releaseClaims: false };
    if (cmd === '/changes' && cur === 'state:reviewing') return { next: 'state:changes-requested', releaseClaims: false };
    if (cmd === '/release') return { next: 'state:ready-for-review', releaseClaims: true };
  }
  return { next: null };
}

审查机自己领活,不靠派

「派」的意思是 PR 一打开就决定由谁审:轮流分,或者分给手上活最少的那台。派活的时机恰好是信息最少的时候。你不知道 4 号机手上那个 PR 是五分钟的文案修改,还是要看两小时的鉴权改动。

我在本机写了一个很小的模拟,代码放在套件里的 sim.py。条件是:三台开发机合计每小时 2.5 到 3 个 PR,泊松到达;两台审查站,每个 PR 先过 6 分钟 AI 首轮,再过人工闸门,人工耗时中位数 15 分钟;另有 15% 是重 PR,人工要 60 到 120 分钟。平均每个 PR 审 34 分钟。每种策略跑 200 个工作小时、20 个随机种子,只算 PR 打开到开始被审之间的等待。

每小时 PR 数(每台审查站利用率) 策略 等待中位数 等待 P90 最坏一次
2.5(0.71) 开 PR 时轮流派 11.9 分钟 125 分钟 293 分钟
2.5(0.71) 派给排队最短的 7.3 分钟 96 分钟 239 分钟
2.5(0.71) 共享队列,谁空谁领 8.2 分钟 82 分钟 174 分钟
3.0(0.85) 开 PR 时轮流派 63 分钟 248 分钟 432 分钟
3.0(0.85) 派给排队最短的 42 分钟 179 分钟 345 分钟
3.0(0.85) 共享队列,谁空谁领 41 分钟 166 分钟 270 分钟

这是模拟,不是你们团队的实测,参数是我按常见情况拍的。但形状很稳:聪明一点的派活,中位数能追上来,尾巴追不上。派活只看得到队列有多长,看不到排在队头的是一个两小时的重 PR。最坏的那一次,正是你在群里被催的那一次。还有一行值得看:利用率到 0.85 时,就算谁空谁领,等待中位数也有 41 分钟。调度救不了产能不足,这时候要靠 WIP 上限让上游停下来。

所以审查站不收派来的活,自己跑一个认领脚本:手上不到两个就去领,先领 prio:high,再领最老的,跳过自己这台机器开的 PR。GitHub 的标签没有比较并交换这种原子操作,两台机器可能同时贴上 reviewer:*,所以贴完等三秒重读一遍,按机器号字典序最小的留下,输的那台摘掉自己的标签去领下一个。开发机跑同一个脚本的 dev 模式:先修自己被打回的 PR,手上已经有两个没合并的就不再开新的。

scripts/claim-next-pr.sh

#!/usr/bin/env bash
# 审查机(4/5/7 号)领下一个 PR;开发机(1/2/3 号)查下一件该做的事。
#   scripts/claim-next-pr.sh review m4      # 审查机:WIP 没满就认领最老的待审 PR
#   scripts/claim-next-pr.sh dev m1         # 开发机:先处理被打回的,WIP 满了就别开新 PR
# 依赖 gh(已登录)和 jq。GH 可换成别的可执行文件做测试;DRY_RUN=1 只打印不改。
set -euo pipefail

ROLE=${1:?role: review|dev}
ME=${2:?machine id, e.g. m4}
GH=${GH:-gh}
REVIEW_WIP=${REVIEW_WIP:-2}
DEV_WIP=${DEV_WIP:-2}
SETTLE=${SETTLE:-3}
FIELDS=number,title,createdAt,labels,isDraft

prs() { "$GH" pr list --state open --limit 200 --json "$FIELDS" "$@"; }
edit() { if [[ ${DRY_RUN:-0} == 1 ]]; then echo "DRY_RUN: gh pr edit $*"; else "$GH" pr edit "$@" >/dev/null; fi; }

if [[ $ROLE == dev ]]; then
  mine=$(prs --label "from:$ME")
  bounced=$(jq -r '[.[] | select(any(.labels[]; .name == "state:changes-requested"))] | sort_by(.createdAt) | .[0].number // empty' <<<"$mine")
  if [[ -n $bounced ]]; then echo "fix #$bounced"; exit 0; fi
  open=$(jq '[.[] | select(.isDraft | not)] | length' <<<"$mine")
  if (( open >= DEV_WIP )); then echo "wip-full $open/$DEV_WIP"; exit 0; fi
  echo "new-work"; exit 0
fi

[[ $ROLE == review ]] || { echo "unknown role $ROLE" >&2; exit 2; }

held=$(prs --label "reviewer:$ME" --label state:reviewing | jq 'length')
if (( held >= REVIEW_WIP )); then echo "wip-full $held/$REVIEW_WIP"; exit 0; fi

# 候选:待审、没被任何机器认领、不是自己机器开的(from:mX 和 reviewer:mX 同号时跳过)。
# 排序:prio:high 在前,然后按创建时间最老的在前。
mapfile -t queue < <(prs --label state:ready-for-review | jq -r --arg me "$ME" '
  [ .[]
    | select(.isDraft | not)
    | select(all(.labels[]; (.name | startswith("reviewer:")) | not))
    | select(all(.labels[]; .name != ("from:" + $me)))
  ]
  | sort_by([ (if any(.labels[]; .name == "prio:high") then 0 else 1 end), .createdAt ])
  | .[].number')

for n in "${queue[@]}"; do
  edit "$n" --add-label "reviewer:$ME" --add-label state:reviewing --remove-label state:ready-for-review
  [[ ${DRY_RUN:-0} == 1 ]] && { echo "claim #$n"; exit 0; }
  sleep "$SETTLE"
  # GitHub 标签没有 compare-and-swap。两台机器同时贴了 reviewer:*,按机器号字典序最小的留下。
  winner=$("$GH" pr view "$n" --json labels | jq -r '[.labels[].name | select(startswith("reviewer:"))] | sort | .[0]')
  if [[ $winner == "reviewer:$ME" ]]; then echo "claim #$n"; exit 0; fi
  edit "$n" --remove-label "reviewer:$ME"
  echo "lost #$n to $winner, trying next" >&2
done
echo "queue-empty"

WIP 上限设成 2,有两个原因。其一,审查站上 AI 首轮跑完、PR 还在等人看的这段时间,PR 一直挂在这台机器名下,上限保证 AI 跑在人前面最多两个,不会攒出一堆「AI 看过了,人还没看」的 PR。其二,上限是给上游的信号:审查队列满了,开发机的 dev 模式会返回 wip-full,代理该去拆 PR、补测试、修被打回的那些。7 号机在这里有了用处:队列深就跑 review m7,队列空就跑 dev m7。

AI 可以打回,不能放行

审查站的循环是这样的:领到 PR,AI 读 diff 做首轮检查,结果以 COMMENT 形式发出去。第一行写着 BLOCKING 就自动评论 /changes 打回,否则留给人工闸门。AI 只做能客观判定的事:改了行为有没有测试、改动是不是超出了 PR 声明的范围、有没有明文密钥、迁移能不能回滚、有没有只在 Windows 上会坏的写法、是不是改了 CI。提示词里明确禁止它写 LGTM。

scripts/review-station.sh

#!/usr/bin/env bash
# 4/5 号机常驻循环:领 PR → AI 首轮 → 机械问题直接打回,其余留给人工闸门。
# AI 只能打回(/changes),不能批准。批准永远是人点的。
set -euo pipefail
ME=${1:?machine id, e.g. m4}
AI=${AI:-claude -p}   # 或 "codex exec";需要能从 stdin 读 diff
while true; do
  out=$(scripts/claim-next-pr.sh review "$ME")
  case $out in
    "claim #"*)
      n=${out#claim #}
      gh pr diff "$n" > "/tmp/pr-$n.diff"
      $AI "$(cat .github/review-prompt.md)" < "/tmp/pr-$n.diff" > "/tmp/review-$n.md"
      gh pr review "$n" --comment -F "/tmp/review-$n.md"
      if head -1 "/tmp/review-$n.md" | grep -q '^BLOCKING'; then
        gh pr comment "$n" --body "/changes"
      else
        echo "#$n 过了首轮,等人工闸门"
      fi
      ;;
    *) sleep 300 ;;
  esac
done

权力是不对称的:打回很便宜,最坏也就是让作者多看一眼;放行有风险。所以 AI 审查用一个单独的机器账号,不写进 CODEOWNERS,也从不 Approve。放行的规则写在规则集里,不靠大家自觉:

.github/CODEOWNERS

# 后匹配的规则覆盖先匹配的。默认任何一位人类都能放行;
# 高风险路径必须由指定的人放行。AI 审查账号不写进这里:它只评论,不批准。
*                       @alice @bob @carol

# 高风险:鉴权、计费、数据迁移、CI 本身
/src/auth/              @alice
/src/billing/           @bob
/migrations/            @alice @bob
/.github/               @alice

# Windows 相关的平台层,由维护 6 号机的人看
/src/platform/win/      @carol
/scripts/win-*.ps1      @carol

ruleset.json

{
  "name": "main-gate",
  "target": "branch",
  "enforcement": "active",
  "conditions": {
    "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
  },
  "bypass_actors": [],
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": true,
        "require_last_push_approval": true,
        "required_review_thread_resolution": true,
        "allowed_merge_methods": ["squash"]
      }
    },
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "required_status_checks": [
          { "context": "test" },
          { "context": "win-verify" }
        ]
      }
    }
  ]
}

用 gh api repos/OWNER/REPO/rulesets --method POST --input ruleset.json 创建。这里面有三个开关,是专门为 AI 代理加的。dismiss_stale_reviews_on_push 让批准之后的新提交作废之前的批准,因为代理在批准后「顺手再改一下」很常见。require_last_push_approval 要求最后一次推送不是批准者本人推的,防止有人让自己的代理推完自己点批准。CODEOWNERS 把 /.github/ 交给一个具体的人,因为改 CI 的 PR,绿勾正是它自己改过的流水线跑出来的,这一点在陌生人的 PR 有人审,Agent 的反而没人看里写过。

strict_required_status_checks_policy 是关着的。打开它,每个 PR 合并前都要先追上 main,再把检查全部重跑一遍,包括 Windows。PR 来得这么快,这等于让 6 号机一直在重跑。冲突交给下面的跟进任务处理。

6 号机是 runner,不靠谁记得去点

在 6 号机上安装 Actions 自建 runner,注册时加一个自定义标签 win-verify,job 用 runs-on: [self-hosted, windows, win-verify] 指过去。新版 action 普遍跑在 Node 24 上,actions/github-script@v9 就写明要 runner 2.327.1 以上。6 号机装最新版 runner,保持自动更新,免得哪天 action 一升级就把它甩下。这台机器会执行 PR 里的代码,所以只能给私有仓库用,不要接外部 fork 的 PR。

这里有一个坑,决定了整个 workflow 的写法。GitHub 文档写得很清楚:被 if 跳过的 job 会报告「Success」,即使它是必需检查,也不会挡住合并。如果把 Windows 那个 job 直接设成必需检查,再用 if 控制只在批准后跑,那么没批准、没跑的 PR 也会显示通过。所以必需检查用的不是 job,是 workflow 自己写的一个 commit status,名叫 win-verify。没跑就没有这个状态,合并按钮就是灰的。

另一个坑:用 GITHUB_TOKEN 贴的标签不会触发新的 workflow 运行。状态机贴上 state:win-verify 这个动作,叫不醒任何监听 labeled 的 workflow。所以 Windows 验证直接监听审查事件(pull_request_review 里状态为 approved),不去监听标签。人手动重贴这个标签,则作为重跑的入口。

.github/workflows/win-verify.yml

name: win-verify
on:
  pull_request_review:
    types: [submitted]
  pull_request:
    types: [labeled]
  merge_group:
  workflow_dispatch:
    inputs:
      pr:
        description: PR number
        required: true

permissions:
  contents: read
  pull-requests: write
  issues: write
  statuses: write

jobs:
  classify:
    if: >-
      (github.event_name != 'pull_request' || github.event.label.name == 'state:win-verify') &&
      (github.event_name != 'pull_request_review' || github.event.review.state == 'approved')
    runs-on: ubuntu-latest
    outputs:
      sha: ${{ steps.c.outputs.sha }}
      pr: ${{ steps.c.outputs.pr }}
      run: ${{ steps.c.outputs.run }}
      need: ${{ steps.c.outputs.need }}
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: .github
      - id: c
        uses: actions/github-script@v9
        with:
          script: |
            const w = require('./.github/scripts/win-verify.js');
            await w.classify({ github, context, core });

  windows:
    needs: classify
    if: needs.classify.outputs.need == 'true'
    # 6 号机注册 runner 时加自定义标签 win-verify
    runs-on: [self-hosted, windows, win-verify]
    timeout-minutes: 45
    concurrency:
      group: windows-box
      cancel-in-progress: false
    steps:
      - name: long paths
        run: git config --global core.longpaths true
      - uses: actions/checkout@v7
        with:
          ref: ${{ needs.classify.outputs.sha }}
          clean: true
      - name: verify
        shell: pwsh
        run: ./scripts/win-verify.ps1

  report:
    needs: [classify, windows]
    # 只改文档时 windows job 被跳过(result=skipped),这里照样跑,负责推到 ready-to-merge
    if: always() && needs.classify.outputs.run == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: .github
      - uses: actions/github-script@v9
        env:
          SHA: ${{ needs.classify.outputs.sha }}
          PR: ${{ needs.classify.outputs.pr }}
          RESULT: ${{ needs.windows.result }}
        with:
          # 想让 auto-merge 产生的 push 触发部署,就换成 GitHub App token
          github-token: ${{ secrets.FLOW_BOT_TOKEN || github.token }}
          script: |
            const w = require('./.github/scripts/win-verify.js');
            const { SHA: sha, PR: pr, RESULT: result } = process.env;
            await w.report({ github, context, core, sha, pr, result });

classify 先看改动的文件:只改了文档,就直接写一个成功状态,不去 6 号机排队。report 回写状态之前,会先核对 PR 的 head 还是不是刚才测的那个 sha。测的过程中又来了新提交,结果只记在旧 sha 上,不改标签。Windows 机只有一台,concurrency: windows-box 让任务排队,不会并行打架。ps1 里放的是你们项目真正的构建和测试,外加一些只在 Windows 上会坏的冒烟检查:路径分隔符、大小写不敏感、CRLF、文件被占用、长路径。

为什么放在批准之后再验,不在每次推送时都验?6 号机是全队最稀缺的资源,批准前的提交大多还会再改。代价是:Windows 一失败,PR 就回到 changes-requested,修完要再审一次。如果你们的 Windows 构建只要几分钟,或者 Windows 问题很多,就把触发器改成 pull_request: synchronize,在审查之前先验。

跟进交给定时任务

每个状态各自的闹钟:待审 60 分钟提醒、120 分钟升级、240 分钟全员停开新 PR;认领 180 分钟自动回收

一个 workflow 每 30 分钟扫一遍打开的 PR,按它在当前状态里待了多久,决定该提醒谁。计时起点是当前状态标签最后一次被贴上的时间,从 issue 事件里读出来,所以每进入一次新状态,闹钟就重新开始。每一级只提醒一次,评论里带一个隐藏标记,下一轮扫到就跳过。

几条规则背后的考虑如下。待审 60 分钟提醒审查机的主人,120 分钟升级给值班的人,240 分钟@所有人,并让开发机停开新 PR。认领 180 分钟还没结论,自动摘掉 reviewer:*,放回队列,因为认领了却不干,是拉模式最常见的泄漏。Windows 验证 60 分钟没结果,多半是 runner 掉线,提醒的是维护 6 号机的人,不是作者。同一轮扫描还会顺手做两件事:和 main 冲突的 PR 贴上 needs-rebase;重写一个带 flow:board 标签的看板 issue,按状态列出每个 PR 停了多少分钟。谁想知道现在卡在哪,打开这一个 issue 就够了。

.github/workflows/followup.yml

name: followup
on:
  schedule:
    # UTC 01:07–13:37,即台北 09:07–21:37;避开整点,整点的定时任务最容易被延迟
    - cron: '7,37 1-13 * * 1-5'
  workflow_dispatch:
    inputs:
      dry_run:
        description: 只打印要做的动作,不写回
        type: boolean
        default: true

permissions:
  contents: read
  pull-requests: write
  issues: write

concurrency:
  group: followup
  cancel-in-progress: false

jobs:
  sweep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          sparse-checkout: .github
      - uses: actions/github-script@v9
        env:
          DISPATCH_DRY_RUN: ${{ inputs.dry_run }}
          # 仓库变量 FOLLOWUP_LIVE 设成 true 之前,定时运行只打印不写回
          LIVE: ${{ vars.FOLLOWUP_LIVE }}
        with:
          script: |
            const f = require('./.github/scripts/followup.js');
            const dryRun = context.eventName === 'workflow_dispatch'
              ? process.env.DISPATCH_DRY_RUN === 'true'
              : process.env.LIVE !== 'true';
            const { md } = await f.run({ github, context, core, dryRun });
            await core.summary.addRaw(md).write();

谁是值班、哪台机器属于谁、每级阈值多少,全写在一个配置文件里,换班改一行:

.github/flow.json

{
  "humans": ["@alice", "@bob", "@carol"],
  "onDuty": "@alice",
  "machineOwner": {
    "m1": "@alice", "m2": "@bob", "m3": "@carol",
    "m4": "@alice", "m5": "@bob", "m6": "@carol", "m7": "@carol"
  },
  "windowsOwner": "@carol",
  "reviewWip": 2,
  "devWip": 2,
  "followup": {
    "state:ready-for-review": [[60, "ping-reviewers"], [120, "escalate"], [240, "stop-the-line"]],
    "state:reviewing":        [[90, "ping-claimer"], [180, "release-claim"]],
    "state:changes-requested":[[120, "ping-author"], [480, "escalate"]],
    "state:win-verify":       [[60, "ping-windows"], [120, "escalate"]],
    "state:ready-to-merge":   [[30, "ping-author"]],
    "state:dev":              [[1440, "ping-author"]]
  }
}

定时任务有三点要知道。cron 用的是 UTC,上面那条对应台北时间工作日 09:07 到 21:37。整点前后负载高,任务可能被推迟甚至丢掉,所以避开了整点。它只在默认分支上跑,workflow 文件要先合进 main 才会生效。仓库变量 FOLLOWUP_LIVE 设成 true 之前,定时运行只打印打算提醒谁,不发评论,也不改标签。第一周就这样看着,阈值顺了再打开。

并行分支:先用 rebase 提醒,再考虑 merge queue

三台开发机同时改一个代码库,冲突是常态。第一道防线和工具无关:PR 要小,一个 issue 对一个分支,两台机器不同时认领同一块代码。第二道是上面那个扫描:main 一动,冲突的 PR 就被贴上 needs-rebase,作者机器上的代理 rebase 后推上来,状态不变。

merge queue 解决的是另一类问题:两个 PR 各自都是绿的,合在一起才坏,也就是语义冲突。它的做法是把排队的 PR 和最新的 main 合成临时分支,在 merge_group 事件上把必需检查再跑一遍。代价也在这里:所有必需检查都得响应 merge_group,其中就包括 win-verify。只有一台 Windows 机,队列的吞吐就等于 6 号机的吞吐。上面的 workflow 已经监听了 merge_group,也会跳过只改文档的组,但这改变不了这个上限。

我的建议是分两步。先不开 merge queue,靠小 PR、冲突标签,再加一条 main 上的推送后检查。等到 main 因为语义冲突一周坏一次以上,再开队列。开的时候同时做两件事:给 Windows 加第二台 runner,或者用队列的合并上限把几个 PR 攒成一组一起验。

这套设计自己会怎么坏

标签被人手改乱。 有人手动贴了第二个 state:*,状态机就有了两个真相。跟进脚本每轮都会检查,并对数量不等于一的 PR 发警告,但它不会替你决定哪个是对的。

6 号机掉线。 win-verify 会一直停在等待,合并被挡住。这是故意的,挡住总比悄悄放过好,60 分钟的提醒就是为它准备的。

提醒变成噪音。 如果大家开始无视提醒,应该调高阈值、减少级数,不要调高频率。一个每半小时都@你的机器人,第三天就会被静音。

两个 workflow 抢着改标签。 一个事件叫醒两个 workflow,两边都读标签、改标签,后写的那个会覆盖先写的。所以每种事件只指定一个 workflow 写状态。你们自己加 workflow 时,也守这条规矩。

定时任务被推迟。 高峰期晚十几分钟很正常,阈值别设成精确到分钟的承诺。

有人用管理员权限绕过规则直接合并。 规则集里 bypass_actors 是空的。如果你们给自己留了绕过权限,就要接受这个后门存在。

冲突状态是懒计算的。 GitHub 的 mergeable_state 要第一次被查询时才开始算,可能先返回 unknown,所以冲突标签有时会晚一轮出现。

什么时候用不上这套

一两个人、一两台机器,交接本来就发生在一个人的脑子里,状态机只会增加仪式感。留下规则集和 CODEOWNERS 就够了。没有 Windows 目标,就删掉 win-verify,同时把 decide() 里批准那一行改成直接进 ready-to-merge,再在那里开 auto-merge。一天不到五个 PR,定时跟进基本就是噪音,手动看一眼 PR 列表更快。这套东西适合的范围,大约是 3 到 10 个人、PR 是一阵一阵涌进来的团队。

明天照这个顺序抄

  1. 把套件里的 .github/ 和 scripts/ 放进仓库,跑 scripts/setup-labels.sh 建好标签,在仓库设置里打开「Allow auto-merge」。
  2. 按你们的实际情况改 CODEOWNERS 和 flow.json 里的人名,用上面那条 gh api 命令创建规则集。必需检查的名字要和你们 CI 里的 job 名一致,而且全仓库唯一。
  3. 开发机的代理开 PR 时统一用 gh pr create --label from:m1,可以写进它的指令文件里。
  4. 6 号机装好 runner,加上 win-verify 标签,把 scripts/win-verify.ps1 改成你们真正的构建命令。
  5. 4、5 号机常驻 scripts/review-station.sh m4,三个人轮流值班做人工闸门。
  6. 第一周不设 FOLLOWUP_LIVE,让跟进任务只打印。阈值调到大家不嫌烦为止,再把它设成 true。

套件下载:pr-flow-kit.tar.gz。里面有三个 workflow、三个脚本、配置文件、规则集、审查提示词、本地测试和排队模拟。

我实际验证了什么

三个 workflow 过了 actionlint 1.7.12,shell 脚本都过了 shellcheck。规则集 JSON 按 GitHub 官方 OpenAPI 描述里创建规则集接口的 schema 校验通过;故意填一个不存在的合并方式,会被拒绝。开启 auto-merge 的 GraphQL 语句按 GitHub 公开的 schema 校验通过。脚本里调用的 Octokit 方法逐个确认存在。状态机、Windows 回写和跟进逻辑在本地用模拟的 GitHub 对象跑了 29 个用例;认领脚本用一个模拟的 gh 跑了 14 个用例,包括两台机器同时认领的竞争。

没测的部分:没有在真实仓库上跑过这几个 workflow,没有真的注册一台 Windows runner,也没有在真实负载下看过 merge queue。review-station.sh 里调用 AI 的那一行只是示意,没有接真实的模型跑过。排队模拟的参数是我拍的,不是你们团队的数据。上线第一周,先别设 FOLLOWUP_LIVE。

机器多了,缺的是一张所有机器都看得见的调度台。把状态写进 GitHub,让审查机自己领活,让定时任务去催,人只在闸门上做判断。

Three people, seven computers. Machines 1, 2 and 3 are for development, each running two or three Claude Code, Codex or Cursor sessions at once. Machines 4 and 5 do review. Machine 6 is the only Windows box and does validation. Machine 7 has no job yet.

Here is how an ordinary afternoon breaks. Around two o'clock the agents on all three dev machines finish at roughly the same time and open eight PRs within a quarter of an hour. Machine 4 is sitting on six of them. Machine 5 is reviewing a large PR that touches authentication and has not moved in an hour. The agent on machine 1 cannot get a verdict, so it starts a new branch, and edits the very file machine 2 just changed. A PR merged on Monday turns out on Wednesday to build a broken path on Windows, because machine 6 only runs when somebody remembers it. "I've looked at this one" is in the group chat, twenty messages up, where nobody will find it. The AI reviewer wrote "LGTM, clean structure" on every PR.

My take: once you have this many machines, the bottleneck moves from writing code to handing it off. A handoff needs one place that every machine reads and writes, and the cheapest such place is GitHub itself. Each PR carries one state label. Review machines pull their own work under a WIP limit. Machine 6 is registered as a self-hosted Actions runner. Follow-up belongs to a scheduled workflow. People stop tracking state and stop dispatching work. They make the calls at the gates.

Every section below comes with files you can drop into a repository. The YAML passes actionlint and the scripts pass shellcheck. The state machine, the follow-up logic and the claim logic were tested locally against a mocked GitHub. The whole kit is linked at the end, together with what I did not test and why.

How seven machines divide the work: dev machines 1–3 open PRs, review stations 4 and 5 pull from the queue, 6 is a self-hosted Windows runner, 7 is flex; a scheduled workflow follows up every 30 minutes, and machines never talk to each other directly

What breaks is the handoff, not capacity

AI speeds up the development side. One agent produces a PR in twenty or thirty minutes, three machines each run two or three sessions, and PRs arrive in bursts. The review side still runs at human speed. A PR that needs judgment takes a person somewhere between ten minutes and two hours.

Queueing theory has a plain identity for this: work in progress = throughput × time in the system. Once human review caps throughput, faster arrivals mean a longer queue and a longer wait for every PR in it. Scheduling cannot change that. What you can change is three things: make the queue visible, let whichever review machine is free take the next item, and make the upstream stop when the queue is full.

Every failure in that afternoon is one of those three missing. PRs pile up on machine 4 because work was pushed there, and machine 5 cannot take it even when idle. Machine 1 collides with a conflicting branch because nothing told it the queue was full and it should go back to its bounced PRs. The Windows bug surfaces late because validation depends on someone remembering. Nobody follows up because the state lives in people's heads, not in the system.

State lives in exactly one place

The PR state machine: state:dev → ready-for-review → reviewing → win-verify → ready-to-merge → merged, plus the fallbacks for bounces, reclaimed claims and invalidated approvals

There is one rule: every open PR carries exactly one state:* label. Two other label families record ownership: a dev machine adds from:m1 when it opens a PR, and a review machine adds reviewer:m4 when it claims one.

Why labels and not custom fields in Projects? Labels are in the PR's webhook payload, one gh pr list --label command queries them, they are visible in the PR list, and a workflow can read and write them with the default GITHUB_TOKEN. Project fields go through GraphQL and need their own permissions. For a three-person team, pick the thing you can query in one terminal line.

Who moves the state? Most transitions fire on events. A PR marked ready goes to ready-for-review. Request changes sends it to changes-requested. A human Approve sends it to win-verify. That last step is written by the Windows validation workflow below, not by the state machine, because one review event wakes both workflows, and if both edit labels they overwrite each other. Only two transitions need an explicit statement from a person or a machine: the author comments /ready after fixing, and the AI on a review station comments /changes when it finds a mechanical problem. Why doesn't a new push in changes-requested return the PR to review automatically? An agent may push five times in one round of fixes. If the first push re-queued it, the reviewer would pick up something half-done.

If new commits land after an approval, the state goes back to reviewing, and back to the same review machine, because the reviewer:m4 label stays. The context is still in the original reviewer's head, and handing it to someone else wastes it.

.github/workflows/pr-state.yml

name: pr-state
on:
  pull_request:
    types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
  pull_request_review:
    types: [submitted]
  issue_comment:
    types: [created]

permissions:
  contents: read
  pull-requests: write
  issues: write

concurrency:
  group: pr-state-${{ github.event.pull_request.number || github.event.issue.number }}
  cancel-in-progress: false

jobs:
  transition:
    if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && startsWith(github.event.comment.body, '/'))
    runs-on: ubuntu-latest
    steps:
      # 永远从默认分支取脚本,PR 里改了状态机脚本也不会在自己身上生效。
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: .github
      - uses: actions/github-script@v9
        with:
          script: |
            const flow = require('./.github/scripts/pr-state.js');
            await flow.run({ github, context, core });

Note that it checks out the default branch, not the PR branch. Otherwise a PR could rewrite the state machine script and have the change apply to itself. The core of the transitions is a pure function, and that is what the tests exercise:

.github/scripts/pr-state.js (excerpt)

function decide({ eventName, action, isDraft, labels, reviewState, commentBody }) {
  const cur = currentState(labels);
  const hasClaim = claims(labels).length > 0;
  const backToReview = hasClaim ? 'state:reviewing' : 'state:ready-for-review';

  if (eventName === 'pull_request') {
    if (['opened', 'reopened', 'ready_for_review'].includes(action)) {
      return { next: isDraft ? 'state:dev' : 'state:ready-for-review', releaseClaims: false };
    }
    if (action === 'converted_to_draft') {
      return { next: 'state:dev', releaseClaims: true };
    }
    if (action === 'synchronize') {
      // 已批准或已在验 Windows 的 PR 又来了新提交:批准作废,回到同一个审查者手里。
      if (['state:win-verify', 'state:ready-to-merge'].includes(cur)) {
        return { next: backToReview, releaseClaims: false, note: '新提交让之前的批准和 Windows 验证作废,回到审查。' };
      }
      return { next: null };
    }
  }

  if (eventName === 'pull_request_review') {
    // approved 由 win-verify workflow 自己写 state:win-verify。同一个事件只让一个 workflow 改标签,免得两边竞争。
    if (reviewState === 'approved') return { next: null };
    if (reviewState === 'changes_requested') return { next: 'state:changes-requested', releaseClaims: false };
    return { next: null };
  }

  if (eventName === 'issue_comment') {
    const cmd = (commentBody || '').trim().split(/\s+/)[0];
    if (cmd === '/ready' && cur === 'state:changes-requested') return { next: backToReview, releaseClaims: false };
    if (cmd === '/changes' && cur === 'state:reviewing') return { next: 'state:changes-requested', releaseClaims: false };
    if (cmd === '/release') return { next: 'state:ready-for-review', releaseClaims: true };
  }
  return { next: null };
}

Review machines pull work; nobody dispatches it

Push assignment means deciding who reviews a PR the moment it opens, either round robin or to whichever machine has the fewest items. That moment is exactly when you know least. You cannot tell whether the PR at the head of machine 4's list is a five-minute copy change or a two-hour authentication rework.

I wrote a small simulation on my own machine; it ships in the kit as sim.py. The three dev machines together produce 2.5 to 3 PRs an hour, with Poisson arrivals. There are two review stations. Each PR gets a 6-minute AI first pass and then a human gate with a median of 15 minutes. A further 15% are heavy PRs that take a person 60 to 120 minutes. The mean review takes 34 minutes. Each policy runs 200 working hours across 20 random seeds, and only the wait between opening a PR and the start of its review is counted.

PRs per hour (utilization per station) Policy Median wait P90 wait Worst wait
2.5 (0.71) Round robin at open 11.9 min 125 min 293 min
2.5 (0.71) Push to shortest queue 7.3 min 96 min 239 min
2.5 (0.71) Shared queue, free station pulls 8.2 min 82 min 174 min
3.0 (0.85) Round robin at open 63 min 248 min 432 min
3.0 (0.85) Push to shortest queue 42 min 179 min 345 min
3.0 (0.85) Shared queue, free station pulls 41 min 166 min 270 min

This is a simulation, not a measurement of your team, and I chose the parameters to match a typical setup. The shape is robust anyway. Smarter pushing catches up on the median but not on the tail. Push assignment sees how long a queue is, not that a two-hour PR sits at its head, and the worst wait is the one people chase you about in chat. One more row matters: at 0.85 utilization, even with pulling, the median wait is 41 minutes. Scheduling cannot fix missing capacity. At that point a WIP limit has to stop the upstream.

So review stations do not accept dispatched work. They run a claim script: if they hold fewer than two PRs, they claim one, prio:high first, then the oldest, and they skip PRs opened by their own machine. GitHub labels have no compare-and-swap, so two machines can add reviewer:* at the same instant. The script waits three seconds, rereads, and the lexicographically smallest machine id keeps the PR. The loser removes its label and moves on. Dev machines run the same script in dev mode: fix your own bounced PRs first, and do not open a new one while two of yours are still unmerged.

scripts/claim-next-pr.sh

#!/usr/bin/env bash
# 审查机(4/5/7 号)领下一个 PR;开发机(1/2/3 号)查下一件该做的事。
#   scripts/claim-next-pr.sh review m4      # 审查机:WIP 没满就认领最老的待审 PR
#   scripts/claim-next-pr.sh dev m1         # 开发机:先处理被打回的,WIP 满了就别开新 PR
# 依赖 gh(已登录)和 jq。GH 可换成别的可执行文件做测试;DRY_RUN=1 只打印不改。
set -euo pipefail

ROLE=${1:?role: review|dev}
ME=${2:?machine id, e.g. m4}
GH=${GH:-gh}
REVIEW_WIP=${REVIEW_WIP:-2}
DEV_WIP=${DEV_WIP:-2}
SETTLE=${SETTLE:-3}
FIELDS=number,title,createdAt,labels,isDraft

prs() { "$GH" pr list --state open --limit 200 --json "$FIELDS" "$@"; }
edit() { if [[ ${DRY_RUN:-0} == 1 ]]; then echo "DRY_RUN: gh pr edit $*"; else "$GH" pr edit "$@" >/dev/null; fi; }

if [[ $ROLE == dev ]]; then
  mine=$(prs --label "from:$ME")
  bounced=$(jq -r '[.[] | select(any(.labels[]; .name == "state:changes-requested"))] | sort_by(.createdAt) | .[0].number // empty' <<<"$mine")
  if [[ -n $bounced ]]; then echo "fix #$bounced"; exit 0; fi
  open=$(jq '[.[] | select(.isDraft | not)] | length' <<<"$mine")
  if (( open >= DEV_WIP )); then echo "wip-full $open/$DEV_WIP"; exit 0; fi
  echo "new-work"; exit 0
fi

[[ $ROLE == review ]] || { echo "unknown role $ROLE" >&2; exit 2; }

held=$(prs --label "reviewer:$ME" --label state:reviewing | jq 'length')
if (( held >= REVIEW_WIP )); then echo "wip-full $held/$REVIEW_WIP"; exit 0; fi

# 候选:待审、没被任何机器认领、不是自己机器开的(from:mX 和 reviewer:mX 同号时跳过)。
# 排序:prio:high 在前,然后按创建时间最老的在前。
mapfile -t queue < <(prs --label state:ready-for-review | jq -r --arg me "$ME" '
  [ .[]
    | select(.isDraft | not)
    | select(all(.labels[]; (.name | startswith("reviewer:")) | not))
    | select(all(.labels[]; .name != ("from:" + $me)))
  ]
  | sort_by([ (if any(.labels[]; .name == "prio:high") then 0 else 1 end), .createdAt ])
  | .[].number')

for n in "${queue[@]}"; do
  edit "$n" --add-label "reviewer:$ME" --add-label state:reviewing --remove-label state:ready-for-review
  [[ ${DRY_RUN:-0} == 1 ]] && { echo "claim #$n"; exit 0; }
  sleep "$SETTLE"
  # GitHub 标签没有 compare-and-swap。两台机器同时贴了 reviewer:*,按机器号字典序最小的留下。
  winner=$("$GH" pr view "$n" --json labels | jq -r '[.labels[].name | select(startswith("reviewer:"))] | sort | .[0]')
  if [[ $winner == "reviewer:$ME" ]]; then echo "claim #$n"; exit 0; fi
  edit "$n" --remove-label "reviewer:$ME"
  echo "lost #$n to $winner, trying next" >&2
done
echo "queue-empty"

The WIP limit is 2 for two reasons. First, while a PR has passed the AI first pass and waits for a person, it stays assigned to that station. The limit keeps the AI at most two PRs ahead of the humans, so you never build up a pile of "the AI looked, no human has." Second, the limit is a signal to the upstream. When the review queue is full, dev mode returns wip-full, and the agent should split PRs, add tests, or fix the bounced ones. Machine 7 finds its job here: run review m7 when the queue is deep, dev m7 when it is empty.

The AI can bounce a PR, never approve one

The station loop works like this. It claims a PR, the AI reads the diff and runs a first pass, and the result is posted as a COMMENT. If the first line says BLOCKING, the loop comments /changes and the PR bounces. Otherwise the PR waits for the human gate. The AI checks only what it can judge objectively: whether changed behavior comes with tests, whether the diff goes beyond what the PR claims, whether there are plaintext secrets, whether a migration can be rolled back, whether anything only breaks on Windows, and whether CI was touched. The prompt explicitly forbids it from writing LGTM.

scripts/review-station.sh

#!/usr/bin/env bash
# 4/5 号机常驻循环:领 PR → AI 首轮 → 机械问题直接打回,其余留给人工闸门。
# AI 只能打回(/changes),不能批准。批准永远是人点的。
set -euo pipefail
ME=${1:?machine id, e.g. m4}
AI=${AI:-claude -p}   # 或 "codex exec";需要能从 stdin 读 diff
while true; do
  out=$(scripts/claim-next-pr.sh review "$ME")
  case $out in
    "claim #"*)
      n=${out#claim #}
      gh pr diff "$n" > "/tmp/pr-$n.diff"
      $AI "$(cat .github/review-prompt.md)" < "/tmp/pr-$n.diff" > "/tmp/review-$n.md"
      gh pr review "$n" --comment -F "/tmp/review-$n.md"
      if head -1 "/tmp/review-$n.md" | grep -q '^BLOCKING'; then
        gh pr comment "$n" --body "/changes"
      else
        echo "#$n 过了首轮,等人工闸门"
      fi
      ;;
    *) sleep 300 ;;
  esac
done

The power is asymmetric. A bounce is cheap; the worst case is that the author takes another look. An approval carries the risk. So the AI reviews under a separate machine account that is not in CODEOWNERS and never approves. The rules for approval live in the ruleset, not in anyone's good habits:

.github/CODEOWNERS

# 后匹配的规则覆盖先匹配的。默认任何一位人类都能放行;
# 高风险路径必须由指定的人放行。AI 审查账号不写进这里:它只评论,不批准。
*                       @alice @bob @carol

# 高风险:鉴权、计费、数据迁移、CI 本身
/src/auth/              @alice
/src/billing/           @bob
/migrations/            @alice @bob
/.github/               @alice

# Windows 相关的平台层,由维护 6 号机的人看
/src/platform/win/      @carol
/scripts/win-*.ps1      @carol

ruleset.json

{
  "name": "main-gate",
  "target": "branch",
  "enforcement": "active",
  "conditions": {
    "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
  },
  "bypass_actors": [],
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": true,
        "require_last_push_approval": true,
        "required_review_thread_resolution": true,
        "allowed_merge_methods": ["squash"]
      }
    },
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "required_status_checks": [
          { "context": "test" },
          { "context": "win-verify" }
        ]
      }
    }
  ]
}

Create it with gh api repos/OWNER/REPO/rulesets --method POST --input ruleset.json. Three switches in it exist specifically because of AI agents. dismiss_stale_reviews_on_push makes a new commit void earlier approvals, because an agent making "one more small change" after approval is routine. require_last_push_approval requires that the last push came from someone other than the approver, so nobody can let their own agent push and then approve it themselves. CODEOWNERS gives /.github/ to a named person, because the green check on a PR that edits CI comes from the very pipeline it edited. I wrote about that in A stranger's PR gets reviewed; the agent's doesn't.

strict_required_status_checks_policy is off. With it on, every PR must catch up with main and rerun every check before merging, Windows included. At this PR rate that keeps machine 6 permanently rerunning. Conflicts are handled by the follow-up job below.

Machine 6 is a runner, not something somebody remembers to click

Install a self-hosted Actions runner on machine 6, give it a custom label win-verify at registration, and point the job at it with runs-on: [self-hosted, windows, win-verify]. New actions run on Node 24, and actions/github-script@v9 states a minimum runner version of 2.327.1. Install the latest runner on machine 6 and leave auto-update on, so an action upgrade doesn't strand it one day. This machine executes code from PRs, so use it only with a private repository, and never with PRs from outside forks.

One trap shapes the whole workflow. GitHub's documentation says it plainly: a job skipped by if reports "Success", and does not block a merge even when it is a required check. Make the Windows job itself a required check, gate it with if so it runs only after approval, and every PR that was never approved and never tested shows as passing. So the required check is not the job. It is a commit status named win-verify that the workflow writes itself. No run, no status, and the merge button stays grey.

A second trap: labels added with GITHUB_TOKEN do not start new workflow runs. When the state machine applies state:win-verify, nothing listening for labeled wakes up. So Windows validation listens to the review event directly (a pull_request_review whose state is approved), not to the label. A person reapplying the label by hand is the entry point for a rerun.

.github/workflows/win-verify.yml

name: win-verify
on:
  pull_request_review:
    types: [submitted]
  pull_request:
    types: [labeled]
  merge_group:
  workflow_dispatch:
    inputs:
      pr:
        description: PR number
        required: true

permissions:
  contents: read
  pull-requests: write
  issues: write
  statuses: write

jobs:
  classify:
    if: >-
      (github.event_name != 'pull_request' || github.event.label.name == 'state:win-verify') &&
      (github.event_name != 'pull_request_review' || github.event.review.state == 'approved')
    runs-on: ubuntu-latest
    outputs:
      sha: ${{ steps.c.outputs.sha }}
      pr: ${{ steps.c.outputs.pr }}
      run: ${{ steps.c.outputs.run }}
      need: ${{ steps.c.outputs.need }}
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: .github
      - id: c
        uses: actions/github-script@v9
        with:
          script: |
            const w = require('./.github/scripts/win-verify.js');
            await w.classify({ github, context, core });

  windows:
    needs: classify
    if: needs.classify.outputs.need == 'true'
    # 6 号机注册 runner 时加自定义标签 win-verify
    runs-on: [self-hosted, windows, win-verify]
    timeout-minutes: 45
    concurrency:
      group: windows-box
      cancel-in-progress: false
    steps:
      - name: long paths
        run: git config --global core.longpaths true
      - uses: actions/checkout@v7
        with:
          ref: ${{ needs.classify.outputs.sha }}
          clean: true
      - name: verify
        shell: pwsh
        run: ./scripts/win-verify.ps1

  report:
    needs: [classify, windows]
    # 只改文档时 windows job 被跳过(result=skipped),这里照样跑,负责推到 ready-to-merge
    if: always() && needs.classify.outputs.run == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.repository.default_branch }}
          sparse-checkout: .github
      - uses: actions/github-script@v9
        env:
          SHA: ${{ needs.classify.outputs.sha }}
          PR: ${{ needs.classify.outputs.pr }}
          RESULT: ${{ needs.windows.result }}
        with:
          # 想让 auto-merge 产生的 push 触发部署,就换成 GitHub App token
          github-token: ${{ secrets.FLOW_BOT_TOKEN || github.token }}
          script: |
            const w = require('./.github/scripts/win-verify.js');
            const { SHA: sha, PR: pr, RESULT: result } = process.env;
            await w.report({ github, context, core, sha, pr, result });

classify looks at the changed files first. If only documentation changed, it writes a success status right away and skips the queue for machine 6. Before report writes the result back, it checks that the PR's head is still the sha that was tested. If new commits arrived during the run, the result is recorded on the old sha only and the labels stay put. There is only one Windows box, and concurrency: windows-box makes runs wait in line instead of colliding. The ps1 holds your project's real build and tests, plus smoke checks for things that only break on Windows: path separators, case-insensitive file names, CRLF, locked files, long paths.

Why validate after approval and not on every push? Machine 6 is the scarcest resource on the team, and most pre-approval commits will change again. The cost: when Windows fails, the PR goes back to changes-requested and needs another review after the fix. If your Windows build takes a few minutes, or Windows bugs are frequent, change the trigger to pull_request: synchronize and validate before review.

Follow-up belongs to a scheduled job

Each state has its own alarm: waiting for review pings at 60 minutes, escalates at 120 and stops new PRs at 240; a claim is reclaimed at 180

A workflow sweeps the open PRs every 30 minutes and decides who to ping based on how long each PR has sat in its current state. The clock starts when the current state label was last applied, read from the issue events, so every new state restarts the alarm. Each level fires once. The comment carries a hidden marker, and the next sweep skips anything already marked.

Here is the reasoning behind the rules. A PR waiting for review pings the review machines' owners at 60 minutes, escalates to the on-duty person at 120, and at 240 mentions everyone and tells the dev machines to stop opening PRs. A claim with no verdict after 180 minutes loses its reviewer:* and goes back to the queue, because claiming without working is the most common leak in a pull system. Windows validation silent for 60 minutes usually means the runner is offline, so it pings whoever maintains machine 6, not the author. The same sweep does two more things. It labels PRs that conflict with main as needs-rebase, and it rewrites one issue labeled flow:board that lists every PR by state with its minutes in that state. Anyone who wants to know where things are stuck opens that one issue.

.github/workflows/followup.yml

name: followup
on:
  schedule:
    # UTC 01:07–13:37,即台北 09:07–21:37;避开整点,整点的定时任务最容易被延迟
    - cron: '7,37 1-13 * * 1-5'
  workflow_dispatch:
    inputs:
      dry_run:
        description: 只打印要做的动作,不写回
        type: boolean
        default: true

permissions:
  contents: read
  pull-requests: write
  issues: write

concurrency:
  group: followup
  cancel-in-progress: false

jobs:
  sweep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          sparse-checkout: .github
      - uses: actions/github-script@v9
        env:
          DISPATCH_DRY_RUN: ${{ inputs.dry_run }}
          # 仓库变量 FOLLOWUP_LIVE 设成 true 之前,定时运行只打印不写回
          LIVE: ${{ vars.FOLLOWUP_LIVE }}
        with:
          script: |
            const f = require('./.github/scripts/followup.js');
            const dryRun = context.eventName === 'workflow_dispatch'
              ? process.env.DISPATCH_DRY_RUN === 'true'
              : process.env.LIVE !== 'true';
            const { md } = await f.run({ github, context, core, dryRun });
            await core.summary.addRaw(md).write();

Who is on duty, which machine belongs to whom, and the thresholds for every level all live in one config file. A shift change is a one-line edit:

.github/flow.json

{
  "humans": ["@alice", "@bob", "@carol"],
  "onDuty": "@alice",
  "machineOwner": {
    "m1": "@alice", "m2": "@bob", "m3": "@carol",
    "m4": "@alice", "m5": "@bob", "m6": "@carol", "m7": "@carol"
  },
  "windowsOwner": "@carol",
  "reviewWip": 2,
  "devWip": 2,
  "followup": {
    "state:ready-for-review": [[60, "ping-reviewers"], [120, "escalate"], [240, "stop-the-line"]],
    "state:reviewing":        [[90, "ping-claimer"], [180, "release-claim"]],
    "state:changes-requested":[[120, "ping-author"], [480, "escalate"]],
    "state:win-verify":       [[60, "ping-windows"], [120, "escalate"]],
    "state:ready-to-merge":   [[30, "ping-author"]],
    "state:dev":              [[1440, "ping-author"]]
  }
}

Three things to know about scheduled workflows. The cron is in UTC; the line above maps to 09:07–21:37 on weekdays in Taipei. Load spikes around the top of the hour, and runs can be delayed or even dropped, so it avoids the hour mark. It only runs on the default branch, so the workflow file must be merged to main before it does anything. Until the repository variable FOLLOWUP_LIVE is set to true, scheduled runs only print who they would ping. They post nothing and change no labels. Watch it that way for the first week and turn it on once the thresholds feel right.

Parallel branches: rebase prompts first, merge queue later

Three dev machines editing one codebase at once will conflict as a matter of course. The first line of defense has nothing to do with tools: small PRs, one issue per branch, and no two machines working the same area at the same time. The second line is the sweep above. When main moves, conflicting PRs get needs-rebase, the agent on the author's machine rebases and pushes, and the state stays the same.

A merge queue solves a different problem: two PRs that are each green and break only when combined, a semantic conflict. It builds temporary branches from queued PRs plus the latest main and reruns the required checks on the merge_group event. That is also where the cost lies. Every required check has to answer merge_group, and that includes win-verify. With a single Windows machine, the queue's throughput equals machine 6's throughput. The workflow above already listens for merge_group and skips documentation-only groups, but that does not lift the ceiling.

My recommendation is to do it in two steps. Start without a merge queue, relying on small PRs, the conflict label, and a post-merge check on main. Once main breaks from semantic conflicts more than about once a week, turn on the queue. When you do, also do one of two things: add a second Windows runner, or use the queue's merge limits to batch several PRs into one validated group.

How this design fails on its own

Labels get edited by hand. Someone adds a second state:* and the state machine now has two truths. The follow-up script checks every sweep and warns about any PR whose count is not exactly one, but it will not decide which one is right.

Machine 6 goes offline. win-verify sits in pending and merges are blocked. That is deliberate: blocking beats silently passing. The 60-minute ping exists for this case.

The pings turn into noise. If people start ignoring them, raise the thresholds and cut levels; do not raise the frequency. A bot that mentions you every half hour gets muted by day three.

Two workflows race to edit labels. One event wakes two workflows, both read the labels and both write them, and the later write wins. So each kind of event gets exactly one workflow that writes state. Keep to that rule when you add workflows of your own.

The schedule runs late. Ten-odd minutes of delay at peak is normal, so do not treat thresholds as minute-precise promises.

Someone uses admin rights to merge around the rules. bypass_actors in the ruleset is empty. If you keep a bypass for yourselves, accept that the back door exists.

Conflict state is computed lazily. GitHub starts computing mergeable_state only when someone first asks for it, and it may report unknown at first, so the conflict label sometimes appears one sweep late.

When you don't need any of this

With one or two people and one or two machines, the handoff already happens inside one person's head, and a state machine only adds ceremony. Keep the ruleset and CODEOWNERS and stop there. With no Windows target, delete win-verify, change the approval line in decide() to go straight to ready-to-merge, and enable auto-merge there. Under five PRs a day, scheduled follow-up is mostly noise, and glancing at the PR list is faster. This setup fits roughly 3 to 10 people whose PRs arrive in bursts.

Copy it tomorrow, in this order

  1. Put the kit's .github/ and scripts/ into the repository, run scripts/setup-labels.sh to create the labels, and turn on "Allow auto-merge" in the repository settings.
  2. Change the names in CODEOWNERS and flow.json to your own, and create the ruleset with the gh api command above. The required check names must match your CI job names and be unique across the repository.
  3. Have every dev agent open PRs with gh pr create --label from:m1. Put it in its instruction file.
  4. Install the runner on machine 6, add the win-verify label, and replace scripts/win-verify.ps1 with your real build commands.
  5. Keep scripts/review-station.sh m4 running on machines 4 and 5, and rotate the three of you through on-duty for the human gate.
  6. Leave FOLLOWUP_LIVE unset for the first week so follow-up only prints. Tune the thresholds until nobody finds them annoying, then set it to true.

Kit download: pr-flow-kit.tar.gz. It contains three workflows, three scripts, the config file, the ruleset, the review prompt, the local tests and the queueing simulation. Comments in the kit are in Chinese.

What I actually validated

The three workflows pass actionlint 1.7.12, and the shell scripts pass shellcheck. The ruleset JSON validates against the schema for the create-ruleset endpoint in GitHub's official OpenAPI description, and a deliberately invalid merge method is rejected. The GraphQL mutation that enables auto-merge validates against GitHub's public schema. Every Octokit method the scripts call was confirmed to exist. The state machine, the Windows report-back and the follow-up logic ran 29 test cases locally against a mocked GitHub object. The claim script ran 14 cases against a mocked gh, including two machines claiming the same PR at once.

What I did not test: these workflows have not run in a real repository. I did not register a real Windows runner, and I have not watched a merge queue under real load. The line in review-station.sh that calls the AI is a sketch and has not been run against a real model. The simulation's parameters are my choices, not your team's data. In the first week, leave FOLLOWUP_LIVE unset.

With more machines, what you lack is one dispatch board that every machine can see. Put the state in GitHub, let review machines pull their own work, let a scheduled job do the chasing, and keep people at the gates.