---
date: 2026-05-19
type: error
tags: [auto-merge-daemon, gitea-api, mergeable, outdated, dispatch-bug]
---

# auto-merge-daemon 在 `block_on_outdated_branch=True` + `mergeable=True` 时卡死，永远不调 update_branch

## 现象

PR #446（docs PR）满足全部合并条件：
- 9 个 required check 全 `success`
- ai-review verdict = `pass`
- 无 REQUEST_CHANGES
- Gitea `mergeable: True`

但 daemon 每 5 分钟 tick 都报错，PR 永远不合：

```
journalctl -u auto-merge-daemon:

  🔀 #446 ... ready: 全检通过 + verdict=pass + mergeable=True
  [error] 合 #446 失败: merge PR #446 → 405: {"message":"The head branch is behind the base branch"}
  [done] merged=0 updated=0
```

`/var/log/auto-merge-daemon/run.log` 同分钟稳定打 `merge_error code=405` + `merge_exception`，连续 30 分钟、6 个 tick。

## 直接原因

`auto-merge-develop.py:208-216` 的 dispatch：

```python
if pr.get("mergeable") is True:
    return ACT_MERGE, "..."        # mergeable=True → 尝试合
if pr.get("mergeable") is False:
    return ACT_UPDATE, "..."       # mergeable=False → 尝试 update
```

作者**假设**：「mergeable=False ⇒ outdated 或真冲突」，所以 update branch 只在 mergeable=False 触发。

**Gitea 实际行为不是这样**：
- `mergeable` 字段**只检测「能否干净三向 merge」**（无 file-level 冲突）
- **不反映** base 是否 outdated
- 一个 PR 跟 develop 没冲突但 base 已经推进 → `mergeable: True` ＋ 仍然 outdated
- 此时调 `POST /pulls/N/merge` → Gitea 在 `block_on_outdated_branch=True` 时拒返回 **405 "head behind base"**
- daemon `merge_pr` 把 ≥300 当作 raise → `main` 接住打 `[error]` → 永远跳过，**永远不调 `/update`**

## 元根因

跨系统语义不一致：Gitea 把「能 merge 吗」拆成两个独立维度：
- `mergeable` 字段：**有无冲突** （Gitea 算的）
- `block_on_outdated_branch` 保护：**是否 outdated**（分支保护 enforcement）

两者**不联动**。`mergeable=True` 不蕴含「调 /merge 一定成功」。

daemon 把这两者合一处理 = bug。在 `block_on_outdated_branch=False` 时不暴露，因为 outdated 不阻挡 merge；2026-05-19 翻成 `True` 后立即暴露——同 PR (#442) 翻规则 + 上线 daemon，自带 latent bug。

## 解法（本 PR 实施）

**应用层 — 修 dispatch**：在 mergeable=True 后主动查 outdated（用 PR 已有字段，免额外 API）：

```python
if pr.get("mergeable") is True:
    # Gitea mergeable=True 只代表无冲突，不反映 outdated。
    # 用 merge_base vs base.sha 判：base 已推进 = outdated。
    full = re_read_pr(api, pr["number"])
    base_sha = (full.get("base") or {}).get("sha")
    merge_base = full.get("merge_base")
    if base_sha and merge_base and base_sha != merge_base:
        return ACT_UPDATE, f"ready except outdated（base={base_sha[:8]} ≠ merge_base={merge_base[:8]}）"
    return ACT_MERGE, "ready: ..."
```

**修第二处 — `update_pr_branch` race 消解判据**：原代码用 `cur.get("mergeable") is True` 当作"peer 已处理"，同样错（mergeable=True 时仍可能 outdated）。改用：

```python
if base_sha and merge_base and base_sha == merge_base:
    return False  # base 已对齐 merge_base = 真不需要 update 了
```

注意：mergeable=False 分支保留 ACT_UPDATE 不变（真冲突时 `update_branch` 会 409 → 加 `LABEL_NEEDS_REBASE` 跳过——既有逻辑正确）。

## 工程化保险

1. **本 PR 不另加测试**：daemon 跑在 host 上 + 用真 Gitea API，单元测试 mock 价值低。本地 DRY_RUN=1 跑一次 + 看 #446 正确分类为 ⬆️ ACT_UPDATE 即足够验证。
2. **未来扩展**：daemon 可以多吐 `merge_base != base.sha` 这个判定结果到 structured log（`outdated_detected` 事件），方便后续监控告警 hook。
3. **跨系统假设审计**：daemon dispatch 还有别的 Gitea 行为假设吗？— `mergeable=None` (未算完) 当成等待 ok；REQUEST_CHANGES check 用 review API 单独走，不依赖 mergeable；目前看其它分支安全。

## 教训

1. **`Gitea mergeable` 字段语义跟 GitHub 完全不一致**：GitHub `mergeable_state` 有 `behind`/`blocked`/`clean` 多个枚举，反映 outdated；Gitea `mergeable` 是裸 boolean，只看冲突。**移植 GitHub 经验写 Gitea 集成时必须二次确认**。
2. **分支保护 + API 行为是分开的两层**：分支保护 (`block_on_outdated_branch`) 不会让 `mergeable` 字段变 False，但会让 `POST /merge` 报 405。**写自动化时不能只查 `mergeable`，得也准备好 405 路径**。
3. **同 PR 翻配置 + 上线 daemon = 互相掩盖的 latent bug 容易漏**：PR #442 同时做 daemon 上线 + `block_on_outdated_branch` 翻 True；测试期间如果没有真 outdated PR，bug 不暴露。下次此类 PR 要专门构造 outdated PR 跑一次。

## 关联

- [ERR-20260519-001-daemon-host-not-git-repo.md](ERR-20260519-001-daemon-host-not-git-repo.md) — daemon 上线第一坑（systemd unit 缺 env）
- [ERR-20260519-002-claude-cli-missing-on-new-runner.md](ERR-20260519-002-claude-cli-missing-on-new-runner.md) — runner 上线第二坑
- [ERR-20260519-003-prisma-db-push-p1014-on-major-schema-rewrite.md](ERR-20260519-003-prisma-db-push-p1014-on-major-schema-rewrite.md) — robot-manager v3 升 test 坑（本 daemon bug 暴露的载体 PR：#446）
- `docs/ops/03-auto-merge-daemon.md` §2.4 「PR 队列自动 update」— 设计文档明说要做的事，但代码实现漏 case
- `docs/standards/16-gitea-actions-platform-semantics.md` §10 — `block_on_outdated_branch` 配置；CLAUDE.md / AGENTS.md 同步漂移：实际 True，CLAUDE.md 仍写 False（PR #442 漏改）
- PR #442 — daemon + 配置翻转的合并 PR
- 触发证据：journalctl 上 daemon `merge_error pr=446 code=405` 连续 6 个 tick；本 ERR 同 PR 修
