🎯 101 Runner 容量门禁「单向棘轮死区」修法(2026-10-16)
- 归属:AgentMeshOS CI runner 容量治理,与
docs/plans/agentmeshos-ci-runner-102-slots-plan.md、docs/plans/agentmeshos-ci-cd-runner-integration-followup-plan.md同族。 - 状态:仓库源改动已完成并本地验证,未上生产。生产副本
/usr/local/lib/agentmeshos/runner-capacity-gate.sh与 systemd 单元未改动,改动上生产需另行评审。 - 事实基准:2026-10-16 只读回读(
journalctl -u agentmeshos-runner-capacity-gate.service近 7 天、GitHub Actions API、101/102 现场快照、仓库源码)。
🗂️ 目录
📌 结论摘要
本次修的是「id6 一旦被门禁停掉就长期回不来」这个缺陷,不是阈值调优。 根因是启动线与停止线之间的不对称滞回区间(下称死区)长期存在时,门禁既不启动也不停止被管槽位;修复方式是保留滞回、增加降级启动线 + 连续达标计数 + 最小运行/停机窗口三层护栏,把"永久离线"降级为"最长 30 分钟后按可维持运行的资源线拉起"。
量化代价(已验证):近 7 天门禁共 219 次采样判定为 resource_gate_closed,其中 98 次(45%)的资源其实已满足"可维持运行"(MemAvailable ≥ 4096 MiB 且 swap_free ≥ 2048 MiB),只是达不到启动线;这 98 次采样在新逻辑下可恢复。7 天内实际发生 action=stop 仅 3 次,说明这不是高频抖动问题,而是单次停机后长期无法自愈。
本地验证已完成:bash scripts/ci/tests/runner-capacity-gate-test.sh 输出 runner_capacity_gate_test=pass,含 9 组新增负向用例;shellcheck -S warning 对改动脚本与测试脚本无告警、退出码 0。
🧩 背景:被管槽位与门禁职责
| 对象 | 事实 | 依据 |
|---|---|---|
| 被管槽位 | 101 的 runner id 6(pcdell-101-agentmeshos),systemd 单元 actions.runner.agentmeshos.pcdell-101-agentmeshos.service |
scripts/ci/runner-capacity-gate.sh:17-20(SECONDARY_SERVICE、SECONDARY_RUNNER_ID=6) |
| 常驻槽位 | 101 的 runner id 7(...-2)为 PRIMARY_SERVICE,门禁只检查其存活、不启停它 |
同文件 service_active "$PRIMARY_SERVICE" 分支只做 no_action reason=primary_runner_inactive |
| 生命周期归属 | id6 在安装时被 systemctl disable,不进 boot transaction,启停完全交给门禁 timer |
scripts/ci/install-runner-capacity-gate.sh:69 注释 Keep the second runner out of the boot transaction |
| 触发频率 | agentmeshos-runner-capacity-gate.timer 每 1 分钟评估一次 |
systemctl list-timers agentmeshos-runner-capacity-gate.timer(2026-10-16 08:44 CST 观测 NEXT ... 57s) |
滞回是刻意设计,不能简单取消:启动线 MemAvailable ≥ 6144 MiB、swap_free ≥ 4096 MiB;停止线 MemAvailable ≥ 4096 MiB、swap_free ≥ 2048 MiB。若把两线对齐,宿主机内存在这条线附近抖动时,门禁会每分钟 start/stop 一次,把 runner 变成 flapping 源。
📊 现状与证据
| 观测项 | 数值 | 证据来源 |
|---|---|---|
| 近 7 天门禁判定分布 | secondary_runner_idle 1066 次、resource_gate_closed 222 次、secondary_runner_busy 52 次、action=start 6 次、action=stop 3 次 |
journalctl -u agentmeshos-runner-capacity-gate.service --since "-7d" 计数 |
| 关闭样本中可维持运行的占比 | 219 次关闭样本里 98 次(45%)满足降级线;120 次 MemAvailable < 4096 MiB;2 次需进一步确认 |
同上,按 metrics 行与 no_action reason=resource_gate_closed 行配对统计 |
| 101 现场即处于死区 | 2026-10-16T00:53:41Z 快照 MemAvailable=6045420 kB(5903 MiB)、SwapFree=8127968 kB(7937 MiB) |
grep -E 'MemAvailable|SwapFree' /proc/meminfo |
| 101 内存被交互式负载占用 | opencode serve 4681 MiB、chrome 637 MiB、Paseo Daemon 561 MiB、desktop ssh opencode serve 498 MiB |
ps -eo rss=,args= --sort=-rss 前 6 行 |
| 同类失效历史 | 2026-10-16 07:12:21 门禁 action=stop failures=mem_available_mb=3856<4096,此后 58 个采样全部无法重启 |
前序只读核查记录(记忆 mem_1792109806717_25szmge0z) |
当前样本(00:53:41Z)就是死区活例:MemAvailable 5903 MiB 高于停止线 4096 MiB、低于启动线 6144 MiB。若此刻 id6 被停,旧逻辑下它无法自愈;新逻辑下若该状态持续 30 分钟,门禁会按降级线把它拉回。
🔍 根因分析
缺陷是"单向棘轮 + 死区长期化",不是 OOM、不是 GitHub 侧禁用。 判定流程在槽位处于 inactive 时只做一件事:检查启动线,不通过就 no_action reason=resource_gate_closed 退出,没有任何计时、计数或降级路径(改动前 scripts/ci/runner-capacity-gate.sh 末段)。
| 状态 | 旧逻辑行为 | 后果 |
|---|---|---|
| 资源高于启动线 | start | 正常 |
| 资源在两条线之间(死区) | inactive 时什么都不做;active 时保持运行 | 已停在死区的槽位永久 offline |
| 资源低于停止线 | active 时 stop | 正常 |
死区并非罕见:近 7 天 45% 的关闭样本落在死区,因为 101 的交互式负载(opencode serve 等)与 runner 抢内存,MemAvailable 长时间在 5.5–7.0 GiB 区间摆动。
🔧 方案与取舍
| 候选 | 做法 | 优点 | 缺点 | 结论 |
|---|---|---|---|---|
| A:对齐启动线与停止线 | 启动线直接降到 4096/2048 | 删掉死区,改动最小 | 宿主机在 4096 附近抖动时每分钟 start/stop,直接引入 flapping | ❌ 否决 |
| B:只加冷却时间 | start 后 N 分钟不停、stop 后 M 分钟不启 | 抑制抖动 | 不消除死区:长期达不到 6144/4096 时槽位仍永久 offline | ❌ 不足以修缺陷 |
| C:降级启动 + 连续达标 + 最小窗口(本次采纳) | 保留滞回;长期停机后按"可维持运行"的降级线启动;启动需连续采样;加最小运行/停机窗口 | 消除永久 offline;把最坏抖动频率限制到每 MIN_RUN+MIN_DOWN 一次 |
参数增多,需在 systemd 时间戳不可用时给出保守退化行为 | ✅ 采纳 |
取舍要点:宁可让降级启动晚 30 分钟、也不要让宿主机在最紧张时被塞进一个 4 GiB 上限的 runner。因此降级线默认等于停止线(4096/2048),即"资源至少能维持运行"才降级启动,而不是取两者中间值;同时要求停机已持续 1800 s 且连续 2 次采样达标才动手。
🧩 实现
改动文件:scripts/ci/runner-capacity-gate.sh(唯一生产脚本源)、scripts/ci/tests/runner-capacity-gate-test.sh(测试)。
| 参数(env 覆盖名) | 默认值 | 语义 | 依据 |
|---|---|---|---|
RUNNER_CAPACITY_MIN_AVAILABLE_DEGRADED_START_MB |
4096 | 降级启动内存线 | 等于停止线,避免"降级后立刻被停" |
RUNNER_CAPACITY_MIN_SWAP_FREE_DEGRADED_START_MB |
2048 | 降级启动 swap 线 | 同上 |
RUNNER_CAPACITY_DEGRADED_START_AFTER_SECONDS |
1800 | 停机持续多久才允许降级启动 | 30 分钟足够区分"瞬时紧张"与"长期死区" |
RUNNER_CAPACITY_DEGRADED_START_SUSTAIN_SAMPLES |
2 | 降级启动需连续达标次数 | 1 次易被单点尖峰欺骗 |
RUNNER_CAPACITY_START_SUSTAIN_SAMPLES |
2 | 正常启动需连续达标次数 | 抑制"峰值启动、下一分钟停" |
RUNNER_CAPACITY_MIN_RUN_SECONDS |
600 | 启动后最短运行窗口,期间不 stop | 限制抖动频率 |
RUNNER_CAPACITY_MIN_DOWN_SECONDS |
300 | 停止后最短停机窗口,期间不 start | 限制抖动频率 |
RUNNER_CAPACITY_STATE_FILE |
/run/agentmeshos-runner-capacity-gate.state |
连续达标计数持久化 | /run 重启清空,无脏状态跨重启 |
时间基准取自 systemd,不自建时钟:运行时长用 systemctl show -p ActiveEnterTimestamp --value,停机时长用 -p InactiveEnterTimestamp --value,经 date -d 解析为 epoch。时间戳未知时按保守方向退化:未知运行时长不阻止 stop(资源安全优先),未知停机时长不阻止 start(避免复位后重新陷入死区)。这条退化规则是"重启后不会永久 offline"的关键。
决策流(改动后):
| 槽位状态 | 条件 | 动作 |
|---|---|---|
| active + worker 在跑 | 停止线通过 / 不通过 | no_action secondary_runner_busy / defer_stop reason=secondary_runner_busy |
| active + idle | 停止线通过 | no_action secondary_runner_idle(并清零计数) |
| active + idle | 停止线不通过且运行时长 < MIN_RUN_SECONDS |
defer_stop reason=min_run_window |
| active + idle | 停止线不通过、运行窗口已过、GitHub 侧确认 idle | action=stop |
| inactive | 启动线通过但连续达标不足 | no_action reason=start_gate_sustain_pending |
| inactive | 启动线通过且停机时长 < MIN_DOWN_SECONDS |
no_action reason=min_down_window |
| inactive | 启动线通过且两个窗口都满足 | action=start reason=resource_gate_open |
| inactive | 启动线不通过但降级线通过且停机 ≥ 1800 s | 连续计数后 action=start reason=degraded_gate_open |
| inactive | 降级线通过但停机不足 | no_action reason=degraded_gate_dormant |
| inactive | 降级线也不通过 | no_action reason=resource_gate_closed ... degraded_failures=... |
🧪 验证
运行命令:bash scripts/ci/tests/runner-capacity-gate-test.sh → runner_capacity_gate_test=pass;shellcheck -S warning scripts/ci/runner-capacity-gate.sh scripts/ci/tests/runner-capacity-gate-test.sh → 无输出、退出码 0。
| 用例 | 输入 | 断言 | 覆盖点 |
|---|---|---|---|
| 既有 12 条 | 沿用原 fixture(新参数按旧行为等价取值) | action=start / action=stop / defer_stop / resource_gate_closed 等全部保持 |
无回归 |
| 连续达标 (a)(b) | 启动线满足,默认参数 | 第 1 次 reason=start_gate_sustain_pending streak=1/2;第 2 次 reason=resource_gate_open |
抗单点尖峰 |
| 计数复位 | 先 1 次不达标、再 1 次达标 | 达标那次仍为 streak=1/2 |
计数不跨断层累积 |
| 降级启动 | mem=5000 MiB、swap_free=3000 MiB、停机 60 min |
两次后 action=start reason=degraded_gate_open |
死区修复主路径 |
| 降级不抢跑 | 停机仅 5 min | reason=degraded_gate_dormant、无 systemctl 调用 |
负向 |
| 降级线不足 | mem=3000 MiB、停机 60 min |
reason=resource_gate_closed 且 degraded_failures=mem_available_mb=3000<4096 |
负向 |
| 降级磁盘不足 | disk=20000 MiB |
degraded_failures=disk_available_mb=20000<30720 |
负向 |
| 最小运行窗口 | active、mem=3000、运行 1 min |
defer_stop reason=min_run_window、无 stop 调用 |
抗抖动 |
| 运行窗口过后 | 运行 20 min | action=stop |
正向 |
| 最小停机窗口 | inactive、启动线满足、停机 1 min | reason=min_down_window、无 start 调用 |
抗抖动 |
| 抖动仿真(运行中) | 资源跨停止线来回 5 轮 | 10 次评估零 start/stop 调用 | 不引入 flapping |
| 抖动仿真(停机中) | 资源跨启动线来回 5 轮 | 10 次评估零 start 调用 | 不引入 flapping |
| 时间戳未知 | InactiveEnterTimestamp 为空 |
仍可降级启动 | 重启后不自锁 |
关键负向结论:在最小运行/停机窗口内,无论资源如何跨线抖动,门禁都不产生任何 systemctl start / systemctl stop 调用(用例"抖动仿真"以 10 次评估、test ! -s "$systemctl_log" 断言)。
真实数据回放(零副作用):用 temp/runner-capacity-gate-replay.sh 把 101 当时的真实 /proc 画像喂给仓库版门禁,systemctl / ps 全部走替身,因此不会动生产服务。
| 项目 | 值 | 说明 |
|---|---|---|
| 回放时刻 | 2026-10-16T00:58:40Z | 真实 MemAvailable=5987 MiB、swap_free=7938 MiB,正好落在死区 |
| 第 1 次评估 | no_action reason=degraded_gate_sustain_pending streak=1/2 downtime_seconds=7200 |
停机 2 小时已过门槛,进入连续计数 |
| 第 2 次评估 | action=start reason=degraded_gate_open |
修法把死区槽位拉回:旧逻辑此时只会输出 resource_gate_closed |
| 副作用核验 | 回放后 systemctl is-active ...pcdell-101-agentmeshos.service 仍为 active,gh api 四个 runner 仍全部 online |
替身吞掉了 start,生产状态未变 |
回放中的 error reason=secondary_health_timeout 是替身不真正启动服务所致,属预期现象,不是缺陷。
🔜 上生产步骤(需评审后执行)
- 评审本文件与
git diff scripts/ci/runner-capacity-gate.sh。 - 走既有安装器备份并落盘:
bash scripts/ci/install-runner-capacity-gate.sh会先建/root/.codex/backups/agentmeshos-runner-capacity-gate-<UTC 时间戳>/(源码可见的备份段)。 - 部署后核验:
sha256sum scripts/ci/runner-capacity-gate.sh /usr/local/lib/agentmeshos/runner-capacity-gate.sh必须一致;systemctl is-active agentmeshos-runner-capacity-gate.timer为active。 - 观察至少 2 个 timer 周期(≥2 分钟)的 journal,确认新字段出现且无
error reason=。
🔁 回退方案
- 源码回退:
git checkout -- scripts/ci/runner-capacity-gate.sh scripts/ci/tests/runner-capacity-gate-test.sh(本次改动未提交前)。 - 生产回退:从安装器生成的备份目录取回旧脚本复原到
/usr/local/lib/agentmeshos/runner-capacity-gate.sh,再systemctl restartgate 无关;gate 为 oneshot,由 timer 触发,无需重启单元。 - 临时停用死区修复(不换脚本):把
RUNNER_CAPACITY_DEGRADED_START_AFTER_SECONDS设成极大值即可让降级分支永不触发;该参数现由脚本内默认值提供,需在 systemd 单元加Environment=才能覆盖,这属于生产配置变更,本任务未做。
⚠️ 风险与未决项
| 类别 | 内容 | 依据 / 下一步 |
|---|---|---|
| ⚠️ 风险 | 降级启动比正常启动少 2048 MiB 内存余量(4096 vs 6144),极端情况下可能在 101 上引入内存压力 | 槽位 drop-in 有 MemoryMax=4G、MemoryHigh=3G、OOMPolicy=stop,最坏结果是该槽位被杀而不是拖垮宿主机;建议先按默认值上线并观察 PSI |
| ⚠️ 风险 | 未知时间戳按"宽松方向"退化,理论上存在 systemd 时间戳丢失后立即降级启动的窗口 | 该窗口只在重启后出现,且槽位从 boot 起确实未运行过;如不接受,可改判为"未知即不降级" |
| ⚠️ 风险 | 45% 死区样本这个比例基于 7 天 journal,样本窗口偏短 | 复现命令:journalctl -u agentmeshos-runner-capacity-gate.service --since "-7d",可按周滚动复算 |
| ❓ 未决 | 2 次"启动线满足但仍报 closed"的样本未定位 | 推断为 psi / load5 / disk 判据单独失败,未逐一核对;如需精确归因需重跑配对分析 |
| ❓ 未决 | 是否同时把 START_SUSTAIN_SAMPLES 从 2 调大(例如 3) |
当前 2 只增加 1 个 timer 周期的启动延迟;调大更稳但恢复更慢,建议维持 2 |
| ❓ 未决 | 101 侧是否保留 id6、是否把常驻槽位从 id7 改成 102 优先 | 与 docs/plans/agentmeshos-ci-runner-102-slots-plan.md 的未决项合并决策 |
📚 相关文件
| 路径 | 作用 |
|---|---|
scripts/ci/runner-capacity-gate.sh |
门禁主体(本次修改) |
scripts/ci/tests/runner-capacity-gate-test.sh |
单元/负向测试(本次扩展) |
scripts/ci/install-runner-capacity-gate.sh |
部署入口,自带备份与 sha256sum 回显 |
docs/plans/agentmeshos-ci-runner-102-slots-plan.md |
4 槽位拓扑与容量决策 |