# Auto-merge daemon

> **版本**: v1.0
> **最后更新**: 2026-05-19
> **取代**: `.gitea/workflows/auto-merge.yml` 的 `schedule:` 触发（保留 `workflow_dispatch:` 应急入口）
> **跟踪 issue**: [#437](http://43.130.59.228/FFAIWorkspace/workspace/issues/437)

---

## 1. 为什么不用 Gitea Actions cron

`auto-merge.yml` 之前 `schedule: */5 * * * *` 在 Gitea Actions 历史里**每天写 288 条 run 记录**，绝大多数是空扫。问题：

- **真有意义的 workflow run 被淹没**——日常调试 Actions 时翻 30 屏才看到非 auto-merge 的 run
- **单 runner 被堆爆时 cron run 被静默 cancel**（status=cancelled / started_at=epoch=1970-01-01），看起来像 cron 停了，实际是没 runner 接（2026-05-18 实测，详见 [.learnings/2026-05-19-cron-cancelled-when-runner-saturated.md](../../.learnings/2026-05-19-cron-cancelled-when-runner-saturated.md)）

Daemon 化把"5 分钟轮询"从 Actions 历史里搬走——Actions 只保留**真有意义的 workflow run**。

---

## 2. 架构

### 2.1 拓扑（双机错峰，无锁 HA）

```
┌────────────────────────────────────────────────────────────────┐
│  Gitea (43.130.59.228)                                         │
│  ├─ PR API (open list, merge, update branch, post comment)     │
│  └─ Branch protection: develop required checks                 │
└─────────────────────▲──────────────────────▲───────────────────┘
                      │                      │
              GET/POST│                      │GET/POST
                      │                      │
┌─────────────────────┴────────┐   ┌─────────┴──────────────────┐
│ dedicated-runner-1 (.48)     │   │ dedicated-runner-2 (.155)  │
│                              │   │                            │
│ systemd timer: *:0/5         │   │ systemd timer: *:2/5       │
│  (00,05,10,15,...)           │   │  (02,07,12,17,...)         │
│                              │   │                            │
│ /opt/auto-merge-daemon/      │   │ /opt/auto-merge-daemon/    │
│   ├── auto-merge-develop.py  │   │   ├── auto-merge-develop.py│
│   └── _gitea_api.py          │   │   └── _gitea_api.py        │
│ /etc/auto-merge-daemon/env   │   │ /etc/auto-merge-daemon/env │
│   (AIBOT_MERGE_TOKEN, 600)   │   │   (AIBOT_MERGE_TOKEN, 600) │
│ /var/log/auto-merge-daemon/  │   │ /var/log/auto-merge-daemon/│
│   run.log (JSONL)            │   │   run.log (JSONL)          │
└──────────────────────────────┘   └────────────────────────────┘
```

**错峰 2 分钟的好处**：
1. 双机 HA——任一 host 挂仍每 5 min 跑一次
2. effective interval 2-3 min，反应比单 5min cron 更快
3. 99% 时间内只有一个 daemon 看到同一个 ready PR，**collision 极罕见**

**无需分布式锁**——Gitea API 自带 atomic + idempotent：
- `POST /pulls/N/merge` 重复调 → 第二次 409 `already merged`，daemon 视为成功跳过
- `POST /pulls/N/update` 重复调 → 第二次 200 no-op 或 `already up to date`

### 2.2 每 tick 行为

```
1. GET /repos/X/Y/pulls?state=open&base=develop
2. for each PR:
     evaluate 7 conditions (复用 auto-merge-develop.py § evaluate)
3. 分类 + 限速:
     ready_to_merge:   首个 7/7 + mergeable=True + base.sha == merge_base
       ├─ re-read PR state (race safety)
       ├─ POST /pulls/N/merge?Do=squash + head_commit_id 锁
       ├─ POST /issues/N/comments  (审计)
       └─ JSONL log
     ready_but_outdated:  首个 7/7 +（mergeable=True 且 base.sha != merge_base，或 mergeable=False）
       ├─ POST /pulls/N/update?style=merge
       └─ JSONL log
     wait_for_gitea:   mergeable=None（Gitea 还没算完）→ skip 下 tick 再看
4. 每 tick 最多 1 merge + 1 update (防 CI 雪崩)
5. 其他 PR (verdict 未到 / check 未过 / opt-out 等): skip 不写 log
```

### 2.3 评判条件（沿用现有 7 条）

| # | 条件 | 来源 |
|---|---|---|
| 1 | `base==develop && state==open` | API |
| 2 | `mergeable is True` 且 `base.sha == merge_base` | API（None=未算 → wait；False=冲突 → update；True 但 outdated → update） |
| 3 | 所有 required status check == success | branch protection + `/commits/<sha>/statuses` |
| 4 | 最新 AIBot review verdict == `pass` | `/issues/N/comments` 倒序找 `AI Review … — pass\b` |
| 5 | 没有未 dismiss 的 REQUEST_CHANGES review | `/pulls/N/reviews` |
| 6 | PR 作者 != AIBot（防递归） | `pr.user.login` |
| 7 | 没有 label `do-not-auto-merge`（逃生口） | `pr.labels` |

**关键**：条件 3（所有 CI 通过）才是真正的"CI 全过"门禁——光看 ai-review verdict 不够。

### 2.4 PR 队列自动 update（解决"PR-A 合后 PR-B outdated"）

`develop` 分支保护 `block_on_outdated_branch=True`（2026-05-19 起，跟本 daemon 同 PR 翻）：PR-A 合后 PR-B 进入 outdated 状态（`base.sha != merge_base`，**注意 Gitea `mergeable` 仍可能是 True**，跟 GitHub `mergeable_state=behind` 不同，详见 ERR-20260519-004）。daemon 检测到 outdated → `POST /pulls/N/update?style=merge`（不用 rebase，保留作者本地 ref 不被重写）→ 下一 tick CI 重跑 + ai-review 因 diff-hash 短路复用 verdict → daemon 合掉。

效果：PR 队列**线性化**——CI 必须在含前序 PR 的 develop 上重跑过才能合，把 "PR-A + PR-B 单独 CI 过、合在一起逻辑冲突"的幽灵 bug 拦在合并前。对人无感（daemon 自动 update）。

**边界**：update 后若**有真 conflict**（base 跟 PR 改了同一行），API 返回 409。daemon log warn + 给 PR 加 label `needs-manual-rebase`，跳过直到 label 被去掉。

### 2.5 审计

每次合 PR 后，daemon 在 PR 下留一条评论：

```
🤖 Auto-merged by daemon@dedicated-runner-2 at 2026-05-19T16:30:21Z
   verdict: pass | checks: 6/6 success | base: up-to-date
```

JSONL 日志 `/var/log/auto-merge-daemon/run.log` 只在**真做事**时写关键行（merged / updated / error），空扫不写——避免重新引入噪音。

---

## 3. 部署

### 3.1 前置依赖

- AIBot 账号生成 `AIBOT_MERGE_TOKEN`（scope `write:repository`），详见 [`automation-accounts.md`](./automation-accounts.md#aibot_merge_token)
- 两台 host 已是 dedicated-runner（已部署 act_runner，详见 [02-gitea-config.md § 3](./02-gitea-config.md#3-actions-runner-当前状态)）

### 3.2 一键部署

```bash
# 本机 → 远端
export AIBOT_MERGE_TOKEN=<新 token>
bash scripts/ops/auto-merge-daemon/deploy.sh 43.166.205.48 0   # OnCalendar=*:0/5
bash scripts/ops/auto-merge-daemon/deploy.sh 43.166.182.155 2  # OnCalendar=*:2/5
```

部署脚本做什么：
1. scp `scripts/ops/auto-merge-develop.py` + `scripts/ops/_gitea_api.py` → `/opt/auto-merge-daemon/`
2. scp systemd unit + timer template，sed 把 `OnCalendar` 替换成 `*:<offset>/5`
3. 写 `/etc/auto-merge-daemon/env`（chmod 600 root:root，AIBOT_MERGE_TOKEN）
4. `systemctl daemon-reload && systemctl enable --now auto-merge-daemon.timer`
5. `systemctl list-timers --no-pager | grep auto-merge` 验证 next run 时间

### 3.3 手动一次性触发（应急）

```bash
# 在 host 上
sudo systemctl start auto-merge-daemon.service       # oneshot 立即跑一次
sudo journalctl -u auto-merge-daemon -n 50 --no-pager
```

或保留的 `.gitea/workflows/auto-merge.yml` `workflow_dispatch:`——daemon 全挂时人能从 Gitea UI 触发。

---

## 4. 运维

### 4.1 健康检查

```bash
# 一行看两台状态
for h in 43.166.205.48 43.166.182.155; do
  echo "--- $h ---"
  ssh ubuntu@$h "sudo systemctl is-active auto-merge-daemon.timer; sudo systemctl list-timers --no-pager | grep auto-merge"
done
```

预期输出：两台都 `active` + next run 各错 2 分钟。

### 4.2 看日志

```bash
# 实时日志
ssh ubuntu@<host> "sudo journalctl -u auto-merge-daemon -f"

# JSONL 结构化日志（更适合 grep）
ssh ubuntu@<host> "sudo tail -50 /var/log/auto-merge-daemon/run.log | jq -c ."
```

### 4.3 token 轮换

跟 `automation-accounts.md` 通用流程一致——新 token 并存窗口期内手动改两台 host 的 `/etc/auto-merge-daemon/env`：

```bash
sudo sed -i "s/^GITEA_API_TOKEN=.*/GITEA_API_TOKEN=<新>/" /etc/auto-merge-daemon/env
# 下一 tick (≤5min) 自动生效，因为 Type=oneshot service 每次启动重读 EnvironmentFile。
# 若需立即验证：sudo systemctl start auto-merge-daemon.service
```

### 4.4 紧急停用

```bash
# 两台都停
for h in 43.166.205.48 43.166.182.155; do
  ssh ubuntu@$h "sudo systemctl stop auto-merge-daemon.timer"
done
```

停后 PR 不会再自动合，所有 PR 走人手合（另一团队成员 web UI Approve → Merge）兜底。

---

## 5. 故障模式

| 症状 | 直接原因 | 处理 |
|---|---|---|
| daemon 报 401 | token 过期或撤销 | 重生成 + 同步两台 host |
| daemon 报 403 on merge | AIBot 不在 develop 分支保护 `merge_whitelist` | Gitea Web UI 加 AIBot 到 whitelist |
| PR 长期不合（已 verdict=pass） | required check 列表跟 branch protection 不一致 | 对比 `evaluate` 输出的 missing 列表 + branch protection 实际配置 |
| 双 daemon 同时 merge 同 PR | 错峰 2 min 内 verdict 出现 + 两台都看到 | 后到的得 409，跳过即可（无副作用） |
| update 后 CI 没重跑 | Gitea webhook 没触发或 ai-review diff-hash 短路了 verdict 已 stale | 手工 close+reopen PR 触发 synchronize；或 daemon 跳过本 PR 等下 tick |
| 两台 host 都挂 | 任意原因 | 走 `workflow_dispatch:` 手动触发 + 人手合兜底 |

---

## 6. 不在范围

- **merge queue**（GitHub-style 临时 merge candidate 分支）：太重，跟 squash-only develop 收益不匹配
- **把 daemon 放 uat-runner host (43.153.69.73)**：那台是 UAT 应用服务器，跟 CI 基础设施职责分离，不混
- **改 `develop → staging` / `staging → production` 的 FF-only merge**：跟本 doc 正交，那条链由 `gitea promote` CLI 管

---

## 7. 相关

- 跟踪 issue：[#437](http://43.130.59.228/FFAIWorkspace/workspace/issues/437)
- 落地实施：脚本 + systemd unit 在本 PR
- 之前 cron 方案：commit `c18461fb feat(ci): 自动合并器（严格 verdict=pass）(#392)`
- 双机 HA 启用 PR：[`#431`](http://43.130.59.228/FFAIWorkspace/workspace/pulls/431)（加 dedicated-runner-2）
- 触发的 learning：[`.learnings/2026-05-19-cron-cancelled-when-runner-saturated.md`](../../.learnings/2026-05-19-cron-cancelled-when-runner-saturated.md)
