---
date: 2026-05-19
tags: [gitea, actions, cron, runner, scheduling, silent-failure]
---

# Gitea Actions cron schedule run 在 runner 满载时被静默 cancel（看起来像 cron"停了"）

## 现象

2026-05-18 用户报告 `.gitea/workflows/auto-merge.yml` 的 5-min cron schedule "突然不跑了"。Actions 历史页里 19:58 之后到 22:18 之间没有成功跑的 run。

但 git log 显示 `.gitea/workflows/auto-merge.yml` 这段时间没改过——cron 配置一直是 `*/5 * * * *`。

## 真相（通过 paginate API + 22 分钟错峰对比定位）

paginate Gitea actions runs API 拉 page 4-5（更老的）发现：

```
#4071 started=2026-05-18T19:58:42  status=success   ← 最后一个真跑的
#4070 started=1970-01-01T08:00:00  status=cancelled
#4079 started=1970-01-01T08:00:00  status=cancelled
... 28 个连续 cancelled，started_at=1970（=epoch=从未启动） ...
#4174 started=2026-05-18T22:18:33  status=success   ← 第一个又跑成功的
```

`started_at=1970` 意味着 Gitea 给这些 run 分配过 run_id（说明 cron schedule **确实触发了**），但**从未被 runner 真启动**。状态显示 `completed/cancelled`——cron 没"停"，是触发后**被静默 cancel**。

22:18 那个时间点 ≈ 我刚启动 `dedicated-runner-2` (.155) 后 1 分 13 秒——单 runner 容量翻倍立刻把 cron 救活。

## 元根因

19:58 → 22:18 之间，唯一在线的 `dedicated-runner-1` (43.166.205.48) 被 PR #426（robot-manager v3 67-commit epic）触发的 build-check 队列堵爆——`Setup Node.js` 跑 8 分钟、`Install dependencies` 跑 19+ 分钟、3 个并发 npm install 互抢 CPU。

cron schedule 在这种情况下：
1. Gitea 每 5 min 触发 → 创建 run record → 等 runner 分配
2. runner 一直 busy → run 进队列
3. **某个 Gitea 内部超时**（未文档化，疑似 ~30 min）后 run 被 cancel，状态写成 cancelled / started_at 留空（=epoch）

**没有日志、没有错误邮件、Actions 页面看不出"被 cancel"是因为容量不够还是因为别的**——`status=cancelled` 跟人工 cancel 长得一样。

## 不直观的事

1. **看 Actions 页面会以为 cron 配置出问题了**——其实是 runner 容量不够，cron 配置完好
2. **加 runner 把队列疏通后 cron 立刻自动恢复**——没有人手动 re-enable / re-trigger
3. **`started_at=1970`** 是关键信号：意味着 run 从未真跑，跟"跑到一半被 cancel"（会有正常 started_at + abort 中段日志）不一样
4. 单看最新 page 1 不会发现这个 pattern——必须 paginate page 4-5 才看到 cancelled 那一段

## 怎么定位

```bash
# 翻 API 找 started_at=1970 的 cancelled runs（说明被静默 cancel）
for page in 1 2 3 4 5; do
  curl -s -H "Authorization: token $GITEA_API_TOKEN" \
    "http://43.130.59.228/api/v1/repos/FFAIWorkspace/workspace/actions/runs?limit=50&page=$page" \
  | python3 -c "
import json, sys
d=json.load(sys.stdin)
for r in d.get('workflow_runs',[]):
    st=r.get('started_at','')
    if st.startswith('1970'):
        print(f\"  #{r['id']} status={r['status']}/{r.get('conclusion')} created={r.get('created_at','')[:19]}\")
"
done
```

连续多个 `1970` cancelled runs 就是"runner 满载 → cron 被静默 cancel"的证据。

## 解法（治本）

把"5 min 空扫"从 Gitea Actions cron 搬出去：改成两台 dedicated runner 上的本地 **systemd timer + daemon** 触发，错峰 2 分钟（`*:0/5` + `*:2/5`）。

落地 PR：本 PR / issue [#437](http://43.130.59.228/FFAIWorkspace/workspace/issues/437)。完整设计见 [`docs/ops/03-auto-merge-daemon.md`](../docs/ops/03-auto-merge-daemon.md)。

**好处**：
- daemon 跑在 runner host 本机的 systemd 上，跟 Actions runner 容量无关——队列堵也不影响 daemon
- 双 host 错峰 = HA + 反应更快（2-3 min effective）
- 不再写 Actions 历史空扫记录，只在真合 / update 时写日志

## 监控点（防再次发生 / 早发现）

1. **Actions 历史出现连续 `started_at=1970` 的 cancelled run**——多半是 runner 满载
2. **某个 workflow 看似"停"**——先翻 page 3-5 确认是不是被静默 cancel 而不是配置改了
3. **Runner busy=true 状态持续 > 10 min**——队列开始积压的早期信号

## 工程化保险

- ✅ 已加 `dedicated-runner-2`（PR #431）→ 单 runner 故障不至于立即堵死
- ✅ 本 PR 把 cron 搬到本地 daemon → 跟 runner 队列解耦
- ⏳ 待办：runner 队列长度 / wait time 监控告警（issue #273 follow-up）

## 跟其他 learning 的关系

跟 [[2026-05-10-gitea-1.26-upgrade-and-act-runner-compat-saga]] 不同——那条是 runner daemon 跨版本 silent hang；本条是 Gitea 端 schedule 自己 cancel。共同点：**Gitea + act_runner 链路上有 ≥ 2 个"日志无、报错无、看起来停了"的静默失败模式**——遇到 workflow 表现异常，先 page 翻深一点看 cancelled run pattern。
