# Orchestrator Skill / Runner 设计模式

> **最后更新**: 2026-05-16
> **下次复查触发条件**: 新增 cron / 定时 orchestrator skill（如 monthly-retro / quarterly-audit）/ 现有 orchestrator 出现新型故障模式（如重复触发 / 死锁）/ 季度复查
> **沉淀来源**: 2026-05 项目已落地的 4 个 orchestrator（weekly-retro / ai-review-runner / auto-merge-develop / sweep-remote-stale）抽取的通用模式
> **适用范围**: 写新 skill 时设计**"既能 Gitea cron 跑、又能用户会话内 @ 触发"**的 orchestrator；写新 ops 脚本（`scripts/ops/*.py|sh`）作为 cron / workflow_dispatch 入口时

---

## 0. 什么是 orchestrator skill / runner

**Orchestrator skill**：自身只编排不实现，把数据采集（脚本）+ pattern 分析（LLM）+ 状态决策（决策树）+ 副作用（POST issue / 评论 / 合并 PR）串成一条流水线，**同一份 skill 同时被以下三种入口触发**：

1. **Gitea cron**（定时） / **workflow_dispatch**（手动）—— 通过 `scripts/ops/<x>-runner.sh` 调用 `claude -p "@<skill> runner mode"`
2. **Claude 会话内的 @<skill>**（交互）—— 用户在 Claude Code 里直接 `@weekly-retro` 触发
3. **`scripts/ops/<x>.py`**（无 LLM 纯 cron）—— 完全脚本化、不调 claude（如 `auto-merge-develop.py` 5 分钟扫一次）

**Runner**：脚本侧的入口（`*-runner.sh` / `*.py`），承担"无 LLM 也能跑"或"封装 claude CLI 调用"的责任。

## 1. 项目已落地的 4 个 orchestrator（对照表）

| Orchestrator | 触发 | 数据源 | 决策 | 副作用 | LLM | 幂等键 |
|---|---|---|---|---|---|---|
| **weekly-retro** | Sat 09:03 cron + `@weekly-retro` + workflow_dispatch | `weekly-review.py`（git + Gitea API + .learnings）| 4 分支决策树（CREATE / NOOP / REPAIR / WARNING）| POST / PATCH Gitea issue + 评论 | ✅ pattern 分析 + 候选改进 | ISO 周 `周复盘 YYYY-Www` |
| **ai-review-runner** | PR opened/sync + `/ai-review` 评论 + workflow_dispatch | PR diff + PR body + docs + 上轮 state header | runner.sh diff hash 短路 + LLM finding 增量 | POST 评论 + 写 commit status | ✅ review verdict + findings | PR head SHA + diff hash |
| **auto-merge-develop** | 5 min cron | Gitea API（PR list + status + reviews + labels）| 7 条 AND 判定（mergeable + checks + verdict pass + no REQUEST_CHANGES + no `do-not-auto-merge` + base==develop + author≠AIBot）| Squash merge PR | ❌ 纯脚本 | PR id（merged 后自然不再 list）|
| **sweep-remote-stale** | Mon 09:13 cron | Gitea `/branches` + patch-id 等价检测 | L1 merge-base / L2 patch-id / ≥30d 长期未处理三层 | POST issue 评论 / 删 orphan 远端分支 | ❌ 纯脚本 | 分支名 + 提交人 username |

---

## 2. 五大设计不变量

### 不变量 1：触发可叠加 + 同一决策树

**同一 skill 必须支持 ≥ 2 种触发方式**（cron 自动 + 用户 @ 交互），共享同一决策树。

实现：skill 通过 prompt 关键字识别"模式"：

```markdown
# In SKILL.md
## 模式（mode）

skill 通过 prompt 关键字识别两种模式：

- **runner mode**（cron / workflow_dispatch）：prompt 含 `runner mode` 字样
  → 不与用户对话、不问问题、按决策树跑完输出 status 退出
- **interactive mode**（默认）：用户 @skill-name
  → 同决策树，但加进度提示 + 完成后给本周改进建议（无强求）

两种模式的**决策树完全相同**，只是 runner 静默、interactive 友好。
```

**反模式**：runner / interactive 分别实现两份决策树——会漂移，cron 跑出来的跟 @ 跑出来的结果不一致。

### 不变量 2：决策树命中**单一分支**

决策树必须每次跑命中**恰好一个分支**（CREATE / NOOP / REPAIR / WARNING / SKIP / ...）。每个分支：

- 输出**结构化**的状态字符串（`CREATED: issue #N, M candidates` / `NOOP: issue #N already complete` / ...）
- 副作用动作清单明确（哪一类分支动什么 API）

**反模式**：

- ❌ 决策树并行执行多分支（如"既创建 issue 又评论"）—— 难以归因后续观测
- ❌ 分支输出"成功"但实际没做事（NOOP 必须显式标"为什么 NOOP"）

### 不变量 3：幂等键必须**稳定**

跨次触发用**稳定 bucket**做幂等键（不是滚动窗口的具体值）：

| 场景 | ✅ 稳定 | ❌ 不稳定 |
|---|---|---|
| 周复盘 | `周复盘 2026-W19` | `周复盘 2026-W19 (May 02 – May 09)` |
| PR review | PR head SHA + diff hash | timestamp |
| 备份验证 | `备份验证 2026-05-W3` | `备份验证 2026-05-15 → 2026-05-16` |

完整规则见 [`docs/standards/17-automation-idempotency.md`](../../../docs/standards/17-automation-idempotency.md) §1。

### 不变量 4：runner 不依赖 user TTY

`*-runner.sh` 跑在 Gitea Actions / cron 环境，**没有交互 TTY**：

- 不能 `claude` 直接起 REPL（要 `claude -p "<prompt>"` one-shot）
- 不能问用户（runner mode 决策树必须无歧义）
- 输出要可被脚本 parse（`grep -q "^CREATED:"` 之类）

**实现规范**（参考 `scripts/ops/weekly-retro-runner.sh`）：

```bash
#!/usr/bin/env bash
set -euo pipefail

TOKEN="${WEEKLY_RETRO_TOKEN:-${GITEA_API_TOKEN:-$GITEA_TOKEN}}"
[ -z "$TOKEN" ] && { echo "ERROR: no token"; exit 1; }

# 构造 prompt：含 runner mode 关键字 + 必要上下文
PROMPT="@weekly-retro runner mode
触发源: Gitea Actions / 手工
仓库: FFAIWorkspace/workspace
当前 ISO 周: $(date +%G-W%V)
..."

claude -p "$PROMPT" 2>&1 | tee /tmp/weekly-retro-output.log

# 解析结构化输出，决定退出码
if grep -qE '^(CREATED|NOOP|REPAIR|WARNING):' /tmp/weekly-retro-output.log; then
  exit 0
elif grep -q '^ERROR:' /tmp/weekly-retro-output.log; then
  exit 1
fi
```

### 不变量 5：dry-run 是免费功能

**任何 orchestrator 都必须支持 `DRY_RUN=1`**：

- 跑全决策树
- **不调写接口**（POST / PUT / PATCH）
- 把"本来要写的内容"打到 stdout，加 `[DRY RUN]` 前缀
- exit code 跟正式跑一样（成功 0 / 失败 1）—— 转正只需删 dry-run 开关

```python
def post_or_dry_run(api, path, data):
    if os.environ.get('DRY_RUN'):
        print(f"[DRY RUN] POST {path}")
        print(f"[DRY RUN] body: {json.dumps(data, ensure_ascii=False)[:500]}...")
        return {"id": "dry-run", "html_url": "https://example/dry-run"}
    return api.post(path, data)
```

完整 dry-run 渐进开关上线模式见 [`16-gitea-actions-platform-semantics.md`](../../../docs/standards/16-gitea-actions-platform-semantics.md) §8.3。

---

## 3. 决策树编写规范

### 推荐分支命名（结合项目实践）

| 分支 | 含义 | 通常副作用 |
|---|---|---|
| **CREATE** | 当前 bucket 内还没东西，创建首次产物 | POST issue / 写 commit status / 创建 PR |
| **NOOP** | 已存在且完整，不需要再做 | 无副作用，只 stdout 输出 |
| **REPAIR** | 已存在但不完整（上次跑挂在中途）| PATCH 补全 body / 重写 state |
| **WARNING** | 已存在 + 用户已 engage（评论 / PATCH 过）| POST advisory 评论提醒，**不动 body**（保护用户已 engage 的版本） |
| **SKIP** | 触发条件不满足（如 PR 作者 = AIBot） | 静默 exit 0 |

### 决策树规模

**多数 orchestrator 4-5 分支够**：

```
启动
   │
   ▼
本 bucket 已有目标 ?
   │
   ├─ NO ──► CREATE
   │
   └─ YES
        │
        ▼
   完整 ?
        │
        ├─ YES ──► NOOP
        │
        └─ NO
             │
             ▼
        用户已 engage ?
             │
             ├─ NO  ──► REPAIR
             │
             └─ YES ──► WARNING
```

超过 6 分支说明**抽象拆分不到位**——把多决策维度拆成多 skill 或单独 step。

### 分支判定 prompt 化（防漂移）

把决策树写到 SKILL.md，**用伪代码 + 走查例**，不是用自然语言描述：

```python
# In SKILL.md §决策树
existing = find_issue_by_title(TARGET_TITLE)

if not existing:
    branch = "CREATE"
elif "## 候选改进" in existing["body"]:
    branch = "NOOP"
elif existing["comments"] > 0:
    branch = "WARNING"
else:
    branch = "REPAIR"
```

LLM 跑 runner mode 时按这段伪代码**逐行核对状态**——比 "你应该判断 X" 强多了。

---

## 4. Runner mode 必备能力清单

写新 runner mode 时必须实现：

- [ ] Token 来源优先级链（`<SKILL>_TOKEN` → `GITEA_API_TOKEN` → `GITEA_TOKEN`），缺 token 立刻退出
- [ ] **5 个**结构化输出之一（`CREATED:` / `NOOP:` / `REPAIR:` / `WARNING:` / `ERROR:`）—— 不能"看起来成功但没归类"
- [ ] DRY_RUN env 支持（不调写接口，stdout 完整 plan）
- [ ] 二次重入安全（同一 bucket 内多次跑都 idempotent）
- [ ] 失败诊断输出（API 5xx 重试 1 次仍 fail 时打印诊断）
- [ ] 不依赖 `/dev/tty`（cron 环境没有交互 TTY）

---

## 5. 已知 pitfall（前作踩过的）

### 自触发循环

orchestrator A 写评论 → 评论里恰好含触发关键字 → 又触发 A → 又写评论 ... 

**典型实例**：`ai-review.yml` 早期 `contains(comment.body, '/ai-review')`，AIBot 自评论引用 `scripts/ops/ai-review-runner.sh` 路径 → 触发自己。

**防御**：

- workflow `if:` 守卫用 `startsWith` 而非 `contains`
- 评论 marker 自识别（开头 `<!-- ai-review-comment -->` 跳过）
- 作者识别（`comment.user.login != 'AIBot'`）

详见 [`17-automation-idempotency.md`](../../../docs/standards/17-automation-idempotency.md) §2。

### Title-based 幂等键含滚动值

跨天调用就不幂等——同 ISO 周 / 同日期被多次 CREATE。

**修法**：title 只保留稳定 bucket（ISO 周、ISO 日、年-季），去掉具体范围。

### state restore 失败留垃圾

orchestrator relax 分支保护字段（如 `enable_status_check=false`）+ merge + restore——**restore 失败时保护字段卡在松状态**。

**修法**：禁止"relax → merge → restore"模式。改用 AIBot 不需要绕过的标准 merge（详见 [`16-gitea-actions-platform-semantics.md`](../../../docs/standards/16-gitea-actions-platform-semantics.md) §2.2）。

### Cron 窗口跟 ISO 周边界错位

`weekly-retro` cron 改日（Mon → Sat）当天，跑出来的 issue 用旧 ISO 周边界，与 next-week 跑出来的视角不一致。

**修法**：

- Cron 日改动当天**接受 NOOP**（同 ISO 周 idempotent 锁）
- 必须补漏 → 改名 title 绕开锁，加"补 (cron 切换间隙)"后缀 + 顶部说明

实例：issue #399（2026-05-16 cron 改日当天补漏）。

---

## 6. 写新 orchestrator 前置 checklist

写新 cron / workflow_dispatch 入口前对照：

### 设计层

- [ ] 是否真需要 LLM？纯规则判定（如 auto-merge）应该走纯脚本，不调 claude
- [ ] 决策树 ≤ 5 分支？超了就拆 skill
- [ ] 幂等键稳定（不含滚动窗口具体值）
- [ ] 自触发循环防御（守卫用 startsWith / marker 自识别 / 作者识别）

### 实现层

- [ ] runner mode + interactive mode 共享同一决策树
- [ ] 5 个结构化输出之一（CREATED/NOOP/REPAIR/WARNING/ERROR）
- [ ] DRY_RUN env 支持
- [ ] Token 来源优先级链
- [ ] 失败诊断输出

### 运维层

- [ ] cron schedule 选合理时段（周六 09:03 = 周末有空消化）
- [ ] workflow_dispatch input 含 `dry-run` 选项
- [ ] 输出文件 / 报告路径文档化（如 `testing/reports/retros/<YYYY-Www>.md`）
- [ ] 告警通道明确（失败时怎么知道）

---

## 7. 已落地实现的参考代码

| Orchestrator | SKILL.md | Runner | 纯脚本（如有） |
|---|---|---|---|
| weekly-retro | `.agents/skills/weekly-retro/SKILL.md` | `scripts/ops/weekly-retro-runner.sh` | `scripts/ops/weekly-review.py`（纯数据采集） + `weekly-retro-issue.py`（issue 写入） |
| ai-review | `.agents/skills/code-review/SKILL.md` § AI Review 段 | `scripts/ops/ai-review-runner.sh` | — |
| auto-merge | — （纯脚本，无 skill） | — | `scripts/ops/auto-merge-develop.py` |
| sweep-remote-stale | — | — | `scripts/ops/sweep-remote-stale.py` |

写新 orchestrator 时**先读最近一个已落地 SKILL.md** + runner 作为模板。

## 8. 配套文档

- [`docs/standards/17-automation-idempotency.md`](../../../docs/standards/17-automation-idempotency.md)（自动化幂等设计模式，决策树外的另一面）
- [`docs/standards/16-gitea-actions-platform-semantics.md`](../../../docs/standards/16-gitea-actions-platform-semantics.md) §8（AI Review 专项约定 + dry-run 渐进开关）
- [`.agents/skills/weekly-retro/SKILL.md`](../../weekly-retro/SKILL.md)（项目最完整的 orchestrator skill 实现样例）
- [`.agents/skills/skill-creator/SKILL.md`](../SKILL.md)（写新 skill 的总入口）
