巡检没跑的那天,我怎么知道

ops-agent 建造笔记第 6 篇:调度系统如何留下运行、超时、中断与错过的证据,daemon 被杀掉或错过执行时怎么发现。

巡检没跑的那天,我怎么知道

《ops-agent 建造笔记》第 6 篇 代码基线:v0.1.0-rc.1,提交 d897d2c95e70(2026-10-25 发布的候选版) 主要代码:agent/schedule.py、agent/tools.py、doctor.py


0. 我先列了一张"它可能怎么骗我"的清单

写定时巡检之前,我先列了一张清单:

  • daemon 昨晚被 macOS 升级重启杀掉了,今早的巡检没跑。我从哪知道?
  • daemon 活着,但某次巡检跑到一半进程被 kill。这次算跑了还是没跑?
  • 机器合盖睡眠,错过了 09:00 的执行时点。是补跑还是跳过?跳过的话我看得见吗?
  • 任务跑了,LLM 调用超时,输出半截结论。这次算成功还是失败?
  • 一个任务卡死,后面的任务全被推迟。这个"推迟"看得见吗?

这是我设计时列的故障推演,不是上线后发生的五起事故。它们共同的风险是:进程可能来不及留下明确错误,查询页面还停在上一次正常结果。对无人值守系统来说,我得让“没有执行证据”成为能被识别的状态。

W3 和 W7 的大部分工作,就是把这五件事一件件揪出来。


1. 每次执行一条记录,六种状态分清楚

每次执行在 ~/.ops-agent/runs/<run_id>.json 留一条记录(0600,保留 200 条),状态机:

已开始:running → succeeded / failed / timeout / interrupted
未执行:missed(独立生成的错过记录,附错过次数)

外加一个 verdict 字段从结果里解析结论(正常/警告/异常/不完整/未判定)。运行状态和巡检结论是两条独立的轴:任务跑成功了(succeeded)但发现异常(verdict=异常),是正常情况,两者不能合并成一个"成功/失败"。

几个状态的判定依据分别是:

timeout 是预算制的,不是单点超时。--timeout 默认 900 秒,允许 60–86400 秒,按 time.monotonic() 算整任务截止时间。轮次与工具调用前检查剩余预算,LLM 请求超时也会裁到剩余时间;workflow 后续步骤显式记为时间预算耗尽。它不是一个能随时杀掉任意 Python 函数的硬实时看门狗,已经启动的调用仍要靠各自的超时与清理路径。

interrupted 靠对账,不靠退出信号。reconcile_runs 在下次启动或 schedule runs 查询时检查 running 记录的 PID,确认进程不在了,再在锁内重读记录并改为 interrupted。这里没有检查目标写锁;目标锁与执行阶段的核对属于第 4 篇的 execution.py,不能混到调度记录上。

已知边界是 PID 复用:同号进程存在时可能少报中断。它也不能靠一个活着的 PID 证明那条任务确实仍在推进,所以我不会把对账写成已经完全解决了进程身份。

missed 靠心跳推断。daemon 每次扫描和每个任务结束后都刷新 schedules.json 里的 meta.last_scan。停机期间的执行时点,用"上次心跳时间"和"本次启动时间"夹出来,记为 missed 并含错过次数。

心跳方案是改过一版的。第一版的思路是"看当前时间距离上次到期执行多久",这个判据有个漏洞:任务自己跑得久或排队,也会让"距上次执行"变长,长任务会被误记成停机错过。改成心跳之后,判定依据变成"daemon 有没有在持续打卡",和任务本身的执行时长解耦。代价是写盘失败时会保守地少报 missed(README 里认了这条)。

外层任务抛异常时最多重试 1 次,默认等待 3 秒,每次尝试用新会话。候选版补了一处边界:等待会裁到剩余时间,到期后不再开始下一次尝试,最后一次异常若已到截止时间,也记为 timeout。BudgetExhausted 明确不重试。这里的重试和 provider 内部的临时错误退避是两层,下一篇会把它们拆开。


2. 并发与推进的几个边角

同一时刻只允许一个调度进程(flock 防双开)。第二个 schedule run 起来会被拒;--once 场景下如果已有调度进程,本次不执行且退出码 3。双开 daemon 的害处是同一个任务被推进两次,这个必须用锁挡,不能靠"记得别开两个"。

扫描先列出候选任务,不把所有任务的 next_run 一次性推进。轮到某条任务真正开始前,_claim_due 才在短锁内复核“仍在、启用、到期”,随后推进这条任务的 next_run。暂停或删除因此能取消尚未开始的排队任务。

要分清被 kill 的是哪一条:已经开始的任务确实已经推进了排期,中断靠 running → interrupted 的记录来交代;后面尚未认领的任务仍然到期,下次扫描还会处理。这个设计避免的是排队任务静默丢失,不是承诺所有中断任务都会自动重跑一次。

单条任务的记录/执行异常不会中断整轮。一个任务失败,尝试写失败记录,再继续下一个。磁盘满时还有尽力写审计的降级路径,但我不能说“至少一定留下一条”:同一块磁盘也可能让审计失败。查询与错误提示要说明记录不完整,不能把 best effort 写成持久性保证。

候选版还补上了更隐蔽的一层:inspect 或 workflow 已经跑过规则检查,模型最后仍回答正常,但完整报告或规则摘要写不进去。工具现在把采集/保存错误记进 ToolContext.evidence_errors,_run_entry 在模型返回后追加程序标记,收尾不再允许把这次结果记成正常;没有更高等级的规则异常时,结论记为不完整。这个决定不能再依赖模型记得转述警告。

run --once 的退出码分四层:0 全部正常;1 有任务失败/超时或启动时对账出中断;2 检查发现警告/异常;3 不完整/未判定/有错过/因已有调度进程未执行。cron 里可以按码分流要不要发提醒。


3. 心跳过期,让调度自己说出来

W7 把心跳再往前推了一步:不光用于事后对账,还用于主动告警。

opsagent schedule status 显示调度心跳与任务总览,心跳过期退出 1。过期阈值不是拍脑袋的固定值,按启用任务的最大超时 + 300s 算。任务最长可能跑多久,心跳间隙就可能有多长,阈值得包住它。

daemon 启动或 --once 进入时调用 alert_heartbeat_if_stale:上次心跳过期就把合成失败记录交给通知状态机;以后再进入这项检查且心跳已 fresh,合成成功记录可触发恢复。不是独立后台探针,也不是宿主死掉时立即发出的告警;还要有进程再次启动、通知配置可用,才可能送达。

这个方案有个结构性盲区:单机部署,宿主彻底宕机时通知发不出去。要防这个,还得把心跳检查放到另一台机器或外部服务上。当前代码没有交付这项外部保障,我把它作为待做项留下。


4. 常驻部署与 doctor

调度要无人值守,还得有服务托管。docs/ops/ 给了 systemd 常驻 service、oneshot + timer 替代方案以及 launchd plist。它们是需要修改路径、运行用户和环境的安装示例,不能把文件存在写成已经部署并长期运行。尤其 systemd 不设 User 会以 root 运行,状态目录也会换到 root 的家目录;常驻与 timer 方案不应同时启动。

配套的是 ./opsagent doctor(W7):LLM(key 缺失 / 鉴权失败 401/403 / 限流 429 / 网络不通,四种情况分开诊断)/ 状态目录 / SSH 逐台探活 / 心跳 / 通知渠道,逐项 ✓/⚠/✗ + 证据 + 修复建议。doctor 会检查配置并发起相应的 API/SSH 探活;我把它当作环境诊断入口,不把一次探活成功当成完整的业务或真实环境恢复验收。候选版还修了目标名过滤:只跳过 _local,_production 这样的合法注册目标也要检查。


5. 现状

候选版的整个项目通过了 309 项自检、223 项独立回归以及四组发布 CI。我的信心仍然有边界:对账让一部分中断不用依赖正常退出时的 finally,却不能抹掉 PID 复用、状态写盘失败或硬中断时的窗口。运行记录让我更容易追问发生过什么,不是把所有死亡方式都变得无关紧要。

本版 W8 的真实 SSH/LLM/通知验收与修复后的连续 7 天观察尚未完成,不能把本地演练 10/10 写成长时间无人值守已经稳定。

还欠着的:任务串行执行,一个长任务会推迟后面的(README 认账,改成并发要动状态机,不值);schedule runs 的记录保留 200 条,长期趋势分析得靠事件时间线。下一篇讲钱:LLM 调用预算和 provider 退避,429 的时候等多久、预算耗尽的时候谁还能干活。


这一版的代码落点


系列总览 · 目录 · 上一篇:exit=0 之后,我为什么还要再验一遍 · 下一篇:我不想等到早上才发现调用没有上限