# 自动化幂等设计模式

> **版本**: v1.0
> **最后更新**: 2026-05-16
> **下次复查触发条件**: 新增定期跑的自动化任务（cron / workflow_dispatch / scheduled agent） / 现有自动化出现重复创建 issue / 重复发送通知 / 重复处理事件类问题 / 季度复查
> **适用范围**: 所有 cron 触发、定时任务、scheduled agent、跨次调用必须保证"同一逻辑窗口内只产出一次"的自动化代码

---

## 0. 为什么需要这份文档

定期触发的自动化（weekly-retro / weekly-stale-sweep / auto-merge / backup cron 等）一旦"同一逻辑窗口内被多次触发"，常见后果：

- 创建重复 issue（同周 2 个 weekly-retro issue）
- 同 SHA 多次评论（自触发循环）
- 同一事件多次发送通知 / webhook
- 备份覆盖未完成的前一轮

幂等设计是自动化的**基础不变量**。本文档把项目踩过的 3 类典型陷阱集中沉淀，写新自动化前先翻一遍。

---

## 1. 陷阱：用"滚动窗口的日期范围"做唯一键 → 跨天调用就不幂等

**真实事故**：`weekly-retro-issue.py` 早期版本用报告 H1 作 issue title：

```
"# Weekly Review · 2026-W19 (May 02 – May 09)"
  → "周复盘 2026-W19 (May 02 – May 09)"
```

`find_existing_issue` 用**精确 title 匹配**找已有 issue 决定 PATCH 还是 POST。

bug 触发：`weekly-review.py` 的窗口是 `now - 7d`（**滚动**）——

| 跑的时间 | 生成的 title |
|---|---|
| 5/9 23:30 | `周复盘 2026-W19 (May 02 – May 09)` |
| 5/10 10:22 | `周复盘 2026-W19 (May 03 – May 10)` |

**同一 ISO 周（W19），但 title 不同** → `find_existing_issue` exact match 失败 → 创建重复 issue (#278 而不是 PATCH #274)。

### 修法：唯一键只用**稳定**的 bucket，不用滚动窗口的具体范围

```python
# ❌ 旧 —— title 含滚动日期，跨天调用不幂等
title = h1.replace("Weekly Review · ", "周复盘 ").strip()
# 输出: "周复盘 2026-W19 (May 02 – May 09)"

# ✅ 新 —— 只保留 ISO 周这个稳定 bucket
title = h1.replace("Weekly Review · ", "周复盘 ").strip()
return re.sub(r"\s*\(.+\)\s*$", "", title)
# 输出: "周复盘 2026-W19"
```

### 规则

**幂等键必须是稳定的逻辑 bucket，不能是滚动窗口的具体值**：

| 场景 | ❌ 不稳定（含滚动值） | ✅ 稳定 bucket |
|---|---|---|
| 周复盘 | "周复盘 2026-W19 (May 02 – May 09)" | "周复盘 2026-W19" |
| 日报 | "日报 2026-05-16 14:30" | "日报 2026-05-16" |
| 月度备份验证 | "备份验证 2026-05-15 → 2026-05-16" | "备份验证 2026-05-W3" or "备份验证 2026-05" |
| 季度复盘 | "复盘 Q2 (Apr 01 – Jun 30)" | "复盘 2026-Q2" |

ISO 日期工具：

```python
date +%G-W%V      # 2026-W19
date +%G-%V       # 2026-19 (no W prefix)
date +%Y-%m       # 2026-05
date +%Y-Q%q      # 季度 (busybox 没有 %q，用 Python)
```

### 副作用：dispatch / 补漏时如何绕开

**已知后果**：同一 bucket 内多次触发，第二次起 NOOP（idempotent）。

如果 cron 改日 / 漏跑 / 想补特殊数据：

- **可接受 NOOP**：下次自然 cron 自然恢复
- **必须补漏**：用**改名 title 绕开 idempotency 锁**，title 加后缀（如 "周复盘 2026-W20 补 (cron 切换间隙)"）+ 顶部说明为什么破坏"1 bucket / 1 issue"约定 + 同 label 保留归档语义

参考 case：本仓库 issue #399（2026-05-16 cron 改日当天补漏的实例）。

---

## 2. 陷阱：`contains()` 子串匹配让自评论触发自循环

**真实事故**：ai-review workflow 用 `contains(github.event.comment.body, '/ai-review')` 判定"用户敲 slash command 触发"。AIBot 自己写 review 评论时引用文件路径如 `scripts/ops/ai-review-runner.sh` 或 `.gitea/workflows/ai-review.yml` —— **子串里恰好有 `/ai-review`** → 守卫通过 → 又跑一次 → 又写一条评论 → 又触发 → 自循环。

被以下机制压平没真正无限循环（但出现过 8 条评论 / 4 个 commit 的"幽灵评论"）：

- runner 幂等短路 `CURRENT_DIFF_HASH == PREV_DIFF_HASH`
- LLM 自己识别为"沿用上轮"返回零增量 finding

### 修法

```yaml
# ❌ 子串匹配，路径里有 /ai-review 就触发
if: contains(github.event.comment.body, '/ai-review')

# ✅ 严格前缀匹配
if: startsWith(github.event.comment.body, '/ai-review')
```

### 规则

**workflow `if:` 守卫匹配用户 slash command 时，默认用 `startsWith()` 不用 `contains()`**，且评论 body 第一个非空字符必须是 `/`。

更进一步：**自动化产生的评论必须能被自己识别并跳过**。两种实现：

1. **Marker 前缀**：所有 AI 评论开头加 `<!-- ai-review-comment -->`，守卫 `! contains(comment.body, 'ai-review-comment')`
2. **作者识别**：守卫 `comment.user.login != 'AIBot'`（更可靠）

本项目当前依赖（1）+ runner 内部的 diff hash 幂等（双层保险）。

---

## 3. 陷阱：写入后状态变更触发的回灌环

**真实事故**：production → staging 升级合并完成后，团队（或自动化）开反向 PR `production → staging` 把 merge commit 同步回 staging。回灌 push 触发 `deploy-uat.yml`（监听 `on: push: branches: [staging]`），UAT 重跑一次（~6min）。staging 实际内容没变，纯浪费。

### 一般化原则

**任何"写入 X 后又自动同步回 Y，Y 又触发 X 上的自动化"形态都要警惕循环**：

```
X 完成 → 触发 Y 同步 → Y push → Y 上的 workflow 跑 → 看到 push 又触发某些动作 → ...
```

### 防御

3 选 1：

| 方案 | 做法 | 适用 |
|---|---|---|
| **A. 触发条件加 author / message filter** | `if: github.event.commits[0].author.name != 'AIBot'` 或 `! startsWith(commit.message, 'Merge branch')` | 自动同步的 commit 有稳定特征 |
| **B. 分离触发路径** | sync-back 走专门分支 / label / paths-ignore，跟正常 push 区分 | 路径明确可分 |
| **C. 接受空跑** | 让它跑（如本项目当前），但加 `paths-ignore` 或 build cache 让空跑 ≤ 10s | 空跑代价低 |

本项目当前对 prod→staging 回灌选 (C)——文档化"看到 staging 又跑 UAT 是正常的，不是 bug"，并优化 deploy-uat.yml 让空 diff 快速 exit。

---

## 4. 检查清单（写新自动化前对照）

- [ ] **逻辑 bucket 定义**：同一次什么算"一次"？写明（ISO 周 / 日历日 / 季度 / 单一 SHA / etc.）
- [ ] **唯一键稳定**：键里不含滚动窗口的具体值；跨天 / 跨小时再触发能命中同一键
- [ ] **二次触发的行为**：NOOP / REPAIR / WARNING 哪个？决策树明确
- [ ] **如何破锁**：用户想补漏 / dispatch / 紧急绕过时的 escape hatch（命名约定 + 文档化）
- [ ] **自评论 / 自触发防御**：workflow `if:` 守卫不依赖 `contains()` 做 slash command 匹配；如有写回行为，标 marker 或 filter author
- [ ] **回灌环防御**：写入 X 是否触发 Y，Y 是否又触发 X？显式画拓扑确认
- [ ] **失败可重入**：第一次跑挂在中途，第二次跑能否从断点恢复？（典型如 weekly-retro REPAIR 分支）
- [ ] **观测可见**：每次跑产出 `CREATED` / `NOOP` / `REPAIR` / `WARNING` 类结构化输出，方便事后回看

---

## 5. 相关 learning 与代码

- [`.learnings/2026-05-10-rolling-window-title-idempotency-trap.md`](../../.learnings/2026-05-10-rolling-window-title-idempotency-trap.md)
- [`.learnings/2026-05-15-ai-review-self-trigger-loop.md`](../../.learnings/2026-05-15-ai-review-self-trigger-loop.md)
- [`.learnings/2026-05-06-prod-to-staging-syncs-retrigger-uat-deploy.md`](../../.learnings/2026-05-06-prod-to-staging-syncs-retrigger-uat-deploy.md)
- [`.agents/skills/weekly-retro/SKILL.md`](../../.agents/skills/weekly-retro/SKILL.md)（决策树范例：CREATE / NOOP / REPAIR / WARNING 四分支）
- [`scripts/ops/auto-merge-develop.py`](../../scripts/ops/auto-merge-develop.py)（5 分钟 cron + 严格 verdict 判定的幂等实现）
- [`docs/standards/16-gitea-actions-platform-semantics.md`](./16-gitea-actions-platform-semantics.md) §6.1（workflow `if:` 守卫的子串陷阱）

---

## 6. 何时更新本文档

- 新增定期触发的自动化任务时，**先翻 §4 checklist** 自检
- 发现新的幂等陷阱模式时，append 到 §1-§3
- 关联 learning 加入 §5
