跳转至

🎯 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 是替身不真正启动服务所致,属预期现象,不是缺陷。

🔜 上生产步骤(需评审后执行)

  1. 评审本文件与 git diff scripts/ci/runner-capacity-gate.sh。
  2. 走既有安装器备份并落盘:bash scripts/ci/install-runner-capacity-gate.sh 会先建 /root/.codex/backups/agentmeshos-runner-capacity-gate-<UTC 时间戳>/(源码可见的备份段)。
  3. 部署后核验:sha256sum scripts/ci/runner-capacity-gate.sh /usr/local/lib/agentmeshos/runner-capacity-gate.sh 必须一致;systemctl is-active agentmeshos-runner-capacity-gate.timer 为 active。
  4. 观察至少 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 restart gate 无关;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 槽位拓扑与容量决策