关于 macos-watchdog
macos-watchdog 是用于设计、部署和诊断 macOS 监控任务及持久作业的 Skill。它强调前提状态检查、克制补救、冷却机制、生命周期匹配和实际验收证据。
主要功能
- 01选择监控触发方式
- 02静默监控安全约定
- 03递增式冷却模式
- 04launchd 作业部署与回读
- 05停止和永久退役流程
适用场景
- 01为 macOS 任务设计监控器
- 02诊断重复告警或应用重启
- 03部署或验证持久 Python 作业
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ daymade/ claude-code-skills --skill macos-watchdog安装后在 Claude Code 中输入/macos-watchdog确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/launchagent.template.plistevals/evals.jsonevals/iteration-1/eval-2-audit/fixture/broken-heal.shbroken-watchdog.plistreferences/alert-discipline.mdevent-driven-observers.mdlaunchd-plist-reference.mdnative-job-readback.mdprobe-cost.mdquiet-watchdog-patterns.mdscripts/inspect_native_job.pynew-launchagent.shwatchdog-cooldown.shtests/test_inspect_native_job.py详细介绍
macOS 看门狗
选择机制前先确定常规入口
选择事件/定期触发器、传输方式或辅助程序之前,先将原始请求中的以下约束记录在当前任务计划中;对于简单任务,不要另建文档:
- **常规入口:**用户实际启动或使用这项工作的入口。如果其启动器只采用部分调用形式或生命周期形式,则根据当前启动器和实际请求的用途推导相关形式;分别记录已采用、已绕过和未经验证的形式,并通过各自实际的使用方验收每项声称支持的形式。
- **手动激活:**请求是否允许交付后再执行额外命令或设置。
- **生命周期:**结果覆盖会话、登录还是整台机器,以及哪些自有组件必须随之启动和停止。
如果请求明确将工作限定为手动运行的辅助程序、选择加入的实验或只读设计,请保留这一范围。否则,应通过常规入口验收,不要要求用户执行未经请求的手动激活。会话绑定组件应复用现有会话所有者;需要在登录或机器范围内持久运行时使用 launchd,不要把每项自动化请求都变成全局监视器。
仅在辅助程序内完成一个业务轮次,只能作为组件级证据:将请求的结果标记为未达成,并继续执行已获授权的集成。对于未经授权或未经观察的旧会话接管以及其他生命周期覆盖,仍应标记为未知;不要停止其他任务,也不要绕过宿主的拒绝来获取验收。
如果安全条件只是临时的,且自动工作必须在之后恢复,则应在同一个运行实例中验证条件解除:先确认没有执行任何禁止操作,再移除该条件,并要求下一个符合条件的事件完成业务操作。使用单独的新实例验证成功、得到无操作结果或查看健康状态标志,都不能证明这一转换;应保留明确的停止、身份丢失和交付状态不确定边界。
应根据所观察的问题选择事件或定期触发器,然后在执行修复前遵守静默看门狗约定。
将看门狗的生命周期绑定到其前提状态。如果它无法修复该状况(WiFi 中断、用户退出目标应用、前置状态消失),就应停止运行,无需等待人工将其禁用。
入口决策树
| 当前情况是…… | 请转到 |
|---|---|
| 从头开始安装一个新的 watchdog | 通过 references/event-driven-observers.md 选择触发方式,然后转到 § 部署和 § 静默 watchdog 合约 |
| 安装、升级、复用或启动持久化 Python 作业 | § 持久化 Python 运行时 |
| 现有 watchdog 行为异常(发送垃圾信息、重新启动应用、频繁触发) | § 静默 watchdog 合约,诊断它违反了哪一条 |
| 停止/禁用/重启作业 | § 停止语义;替换前请遵循 references/event-driven-observers.md § 替换前等待获授权的卸载 |
| 永久退役作业及其工具 | § 停止语义 → 永久停用作业 |
| plist 键详细信息(KeepAlive 形式、域、日志、资源限制) | references/launchd-plist-reference.md |
| 为事实来源选择事件驱动还是定期触发 | references/event-driven-observers.md § 选择事件或定期触发 |
| 部署/验收事件驱动观察器(WatchPaths、FSEvents、inotify 风格) | references/event-driven-observers.md § 部署 SOP 和 § 验收 SOP |
| 检查 plist 编辑是否已生效,或比较作业 argv/显式 ENV/已配置路径/调度键 | 使用准确的预期 plist 运行 scripts/inspect_native_job.py;阅读 references/native-job-readback.md,了解结果、调度标志和源选择规则 |
| 设置或恢复已获授权且有交付期限的持久化日历作业 | references/launchd-plist-reference.md § 日历期限与恢复;分别检查逾期工作和实际交付情况 |
| 冷却/退避/通知限流模式+脱敏实战案例 | references/quiet-watchdog-patterns.md |
| SRE 告警分层(页面通知还是工单、疲劳阈值) | references/alert-discipline.md |
| watchdog 告警显示在 Script Editor 下,或发送方不明确 | references/alert-discipline.md § 消息内容;检查送达的通知卡片 |
静默 watchdog 合约
发布或认可任何 watchdog 前,以下各条都必须满足。每条都源于某个真实 watchdog 的违规案例。
1. 前提状态自检——确认自身仍有存在的必要
脚本每次运行时首先要做的事:验证其存在所依赖的状态是否仍然成立。如果不成立,就静默退出——不执行补救、不发送通知、不产生副作用。
- 代理修复 watchdog 首先检查代理应用是否正在运行;用户退出应用后,就跳过本次检查周期。
- “配置是否切换回去了”观察器会检查它监视的配置状态;如果已经切换,就自行停止,而不是再次发送误导性通知。(真实案例:某个恢复观察器在前提已解除后仍持续触发 2 小时,发送了 3 条错误通知,因为没有任何机制告知它应当停止。)
2. 先修复,只有持续失败时才告警
每个周期的检测都保持如实,但只有当故障连续持续 N 个周期后,才执行具有干扰性的操作(耐心模式)。原因:振荡链路会在几分钟内自行恢复;对会自行消退的短暂故障强制重新连接弊大于利。选择 N 之前,先测量系统实际的自恢复窗口(某条链路的一次 94-min 观测运行显示,自恢复时间 ≤3 min → 以 5-min 间隔设置 N=2 个周期)。
当触发条件将多个独立来源汇总为一个判定(而非单一健康检查)时,跨来源一致率是与 N 相互独立的第二个参数——不要默认要求“所有来源都必须一致”。非此即彼的门槛会在恰恰是看门狗要捕捉的、存在相关性但影响不完整的事件中造成漏报,因为真实故障很少会同时影响所有来源(真实案例:一个包含 9 个来源的采集健康门槛要求 9/9 个来源都故障,结果漏掉了一起发生时仅有 6/9 个来源故障的实际事件;根据实际事件数据重新校准,将门槛调整为 ≥60% 的来源后成功捕捉到该事件,同时通过 0%/11%/100% 的样例测试确认,单一来源短暂故障仍可容忍)。校准广度(必须有多大比例达成一致)的方式,应与校准深度(N)相同:依据“听起来够严格”的直觉选择阈值只是猜测,并非校准。
升级阶梯(从轻到重):刷新状态 → 重启连接 → 远程修复。每一级都要先验证,再进入下一级。
3. 逐级延长自动冷却时间——环境无法修复时保持静默
完整修复阶梯全部失败时,说明看门狗无法修复该环境(WiFi 故障、强制门户、上游服务宕机)。最简单粗暴的做法——每隔一段时间就重新运行完整阶梯并发送通知,永无止境——恰恰就是“看门狗每 10 分钟都会重新启动应用”。
ThrottleInterval 无法解决这个问题:它限制的是进程重启频率,是固定延迟,没有退避机制,而且对退出码为 0、但发送了大量通知的任务毫无作用。冷却逻辑必须位于应用层:
- 在状态文件中记录连续失败的轮次。
- 每次轮次耗尽后,进入逐级延长的停用期(例如 30 min → 2 h → 6 h,之后重复最后一级)。
- 进入冷却状态时通知一次;冷却期间不通知。冷却期结束后重试一轮;任何实际修复成功都会清除计数和冷却状态。
- 手动
pause [duration]命令配合 TTL 状态文件可作为备用方案——但自动流程必须能在完全没有人工命令的情况下运行。要求用户记住命令的禁用机制并不是真正的机制。
可复用的实现:scripts/watchdog-cooldown.sh(导入该脚本;它提供 paused_any、record_exhausted、clear_exhausted、cmd_pause/cmd_resume)。
4. 绝不重新启动用户明确退出的应用
在 macOS 上,当应用尚未运行时,open <url-scheme> 会启动应用;不加 -g 的 open 会抢占前台。看门狗如果通过 URL scheme、open -a 或重启 GUI 应用来修复,用户会觉得“我刚退出,它又回来了”。
执行此类操作前都要先做检查:调用应用的 scheme 前,确认目标进程仍在运行;并传入 -g,确保合法操作绝不会弹出窗口。如果用户在修复过程中退出应用,就中止整个阶梯——清理陷阱也必须遵守同样的检查,否则“退出时确保连接”的备用逻辑就会重新启动应用。
部署(容易踩坑的机制)
部署或集成现有的周期性观测器之前,请先应用
references/probe-cost.md。要求观测工作有界,并且已完成原生
launchd 轮次;导入现有脚本并不能证明其成本或判定可靠。
宣告配置已启用之前,请使用
references/native-job-readback.md 中的原生回读辅助工具。从任务自身的配置块中读取显式 ENV;
不要从默认或继承的环境配置段中读取第一个匹配的键。
应单独验证业务轮次已完成,不要将其与注册状态或进程状态混为一谈。
- 位置:用户代理 →
~/Library/LaunchAgents/(GUI 会话上下文:可以open应用、显示通知);系统守护进程 →/Library/LaunchDaemons/(root,无 GUI 访问权限)。应根据任务是否需要用户的 GUI 会话来选择,而不是凭习惯。 - plist:对于定期任务,从
assets/launchagent.template.plist开始;对于事件任务,遵循references/event-driven-observers.md及其 plist 参考。使用plutil -lint验证。ProgramArguments的第 0 个元素必须是绝对路径;切勿依赖继承的 shell PATH。 - 加载/重新加载:替换或停止现有任务之前,先应用
references/event-driven-observers.md§ Deployment SOP 中的所有权、活动工作和待处理意图检查。对于已获授权且空闲的任务,使用launchctl bootstrap gui/$(id -u) <plist>加载;编辑 plist 后,先执行bootout,再执行bootstrap。使用launchctl kickstart gui/$(id -u)/<label>请求运行一次空闲且归属明确的任务;-k会终止现有实例,不是常规测试。对立即退出的任务执行kickstart -k是节流陷阱,不是延迟探测:终止并重启会与 10 s 的ThrottleInterval冲突,产生spawn failed, error=89 (ECANCELED),并以 10.01–10.03 s 的间隔重启,因此调用会阻塞约 11–20 s。2026-10-20 的测量结果:110 次间隔中有 108 次为 10.01–10.03 s,而普通kickstart在不到 1 s 内返回。不要将这种阻塞误认为 launchd 拥塞。 - 日志:必须设置
StandardOutPath/StandardErrorPath(没有它们,故障就会无迹可寻),并在脚本中进行日志轮转(上限约 1 MB)。 - 幂等性保护:重复运行部署不得重复安装。仅对于定期任务,在完成上述替换检查后使用
scripts/new-launchagent.sh <label> <script> <interval>;它可能会 boot out 已加载的任务。对于事件任务,使用事件 SOP。两种情况下,都要通过原生辅助工具验证已加载的配置,并单独验证已完成的工作。 - TCC / 完全磁盘访问权限:读取受保护文件的 LaunchAgent 需要其有效权限主体具有可用的授权。使用
macos-permissions检查 TCC 归属,并从实际任务中验证受保护文件的读取;仅凭 shell 解释器路径无法确定这一点。 - 默认进行批处理节流:任何会启动工作的看门狗循环(重放、模糊测试、批量扫描、并行 API 调用)都必须将明确的速率上限设为默认参数,而不是留待后续优化。对于机器来说,未节流的循环与失控进程无法区分(真实案例:一次未节流的测试重放每秒派生 1,041 个进程,持续 7 分钟,使芯片温度升至 83 °C)。
- 浏览器副作用验收:仅成功获取数据,并不能证明基于浏览器的看门狗已准备就绪。部署操作员必须为任务拥有的空间和页面声明预算,然后在多个轮次中独立清点,包括注入初始化和清理故障的情况。数量必须始终处于该生命周期预算内;整个过程中都要保护用户拥有的页面和来源不明的页面。如果任务要求不得抢占焦点,还必须在这些轮次中独立观察前台应用和可见窗口;CDP 成功并不能证明没有抢占焦点。对于 ego lite 生命周期、锁定、所有权冲突和清理回读,如果已安装,请加载指定的 Skill
ego-lite-use。否则,使用所属浏览器工具文档所述的生命周期管理方式,同时满足相同的预算和回读要求。缺少资源观察结果,或在要求不得抢占焦点时缺少焦点观察结果,都会导致验收未完成。 - **低于一分钟的
StartInterval会带来 BTM 状态转换开销——高频探测优先使用KeepAlive循环。**每次定期触发都会先进入运行状态再退出;backgroundtaskmanagementd会将每次触发转换为一次存储写入,以及一次合并间隔为 250 ms 的分布式userDataDidChange广播,从而触发 LoginItems.appex 完整重建监视器——这正是 2026-10-06/10-17 看门狗恐慌背后的放大因素。使用进程内循环运行的持久任务(周期 = max(interval, runtime+1s))可以在保持频率的同时,将稳定运行时的状态转换降为零。转换三个问题最严重的任务后,测得由 BTM 触发的监视器更新从 0.45/s 降至约 0.20/s。 - **macOS 会将派生子进程的
XPC_SERVICE_NAME置零——包装探测程序会导致身份保护失效。**launchd 任务进程可以读取自身标签,但它启动的每个子进程(bash/sh/python)读取到的都是XPC_SERVICE_NAME=0,且export的值不会通过exec传递(在 27.0.1 上使用专用探测任务测得)。如果脚本的保护条件是[ "$XPC_SERVICE_NAME" = <label> ],那么只有当脚本本身就是任务进程时才会通过。将它包装在循环/驱动程序中后,探测程序会变成孙进程:保护条件会拒绝 100% 的迭代,但launchctl显示state=running——一个采集器连续 75 分钟未生成任何行。通过驱动程序设置的其他变量(例如通过 argv 传递的<PROJECT>_CALLER_LABEL)传递身份,并让保护条件将其作为替代条件接受;在人类 shell 中直接执行仍会被拒绝,因为这两个变量都未设置。
持久化 Python 运行时
对于持久化 Python 作业,应将依赖准备与常规启动分开:
- 安装/升级: 使用所属安装程序的显式准备操作,根据保留的锁定输入,在 uv 缓存之外创建持久化环境。将选定的 Python 系列、完整的冻结发行包集合和基础解释器绑定到该安装。依赖变更应在此阶段进行。
- 复用: 执行环境中的实际 Python,比较其系列和完整已安装发行包的名称/版本与冻结输入,并验证其解析出的基础 Python 仍然存在且可以运行。就绪标记或仅匹配 Python 系列都无法验证环境。拒绝空环境、不匹配的环境或损坏的环境;恢复执行获准的准备操作,而不是默默接受。
- 运行: 在
ProgramArguments中注册环境 Python 的绝对路径,并直接调用服务入口。如果运行时缺失或无效,应明确报错;不要默默回退到uv run、其他解释器,或在启动时安装。检查继承的变量和启动钩子,确认它们是否可能重定向所选运行时。有限的维护脚本仍可使用其声明的uv run工作流。
切换现有作业之前,保留 § Deploy 中的所有权、活动工作和待处理意图检查。保留规范配置和回滚目标;在替换配置之前,立即将实时配置与已检查版本进行比较,如有偏差则停止。使用现有的原生回读,并通过实际使用方验收完整的业务轮次;标记、PID 或 HTTP 健康响应不能替代该结果。部署操作员负责这些检查;此 Skill 不提供通用环境安装程序。
将权限归属与 Python 环境选择分开处理。如果原生可执行文件需要使用已获授权启动器的 FDA 身份,请遵循 macos-permissions 及其经测量的 uv 归属案例。如果对真实受保护操作的测试证明必须使用该确切的 uv 启动器,则保留经过验证的责任链。对于原生可执行文件,已安装 uv 支持的 uv run --no-project --offline --no-cache -- <absolute-binary> ... 形式可以避免使用共享主缓存,同时保留该启动器;接受此方案前,应从真实作业中验证同一受保护操作。不要将此例外推广到 Python 依赖启动。--no-cache 使用临时缓存;它不保证完全不占用临时磁盘空间,也不保证其他 Python 任务不会下载内容。
请参阅 uv 的环境使用指南,了解直接执行环境;有关 --no-cache,请参阅其缓存文档。
停止语义(已弃用操作的陷阱)
停止或替换任务前,请执行 § Deploy 中的所有权和活动工作检查, 并遵守当前任务的授权边界。
| 意图 | 命令 |
|---|---|
| 立即停止,允许稍后重新引导 | launchctl bootout gui/$(id -u)/<label>(守护进程:sudo launchctl bootout system/<label>) |
| 阻止后续登录时加载(单独停止已加载的任务) | launchctl disable user/$(id -u)/<label>(反向操作:enable) |
| 编辑后重新加载 | bootout → 按部署 SOP 进行有界的精确服务卸载观察 → 编辑 plist → bootstrap |
请使用 bootstrap/bootout,而非已弃用的 load/unload。如果任务必须在后续加载时仍保持禁用状态,请执行 disable;仅停止某个实例并不能设置持久覆盖。
临时任务如果仍处于加载状态,就会持续产生 § Deploy-9 转换开销(每次触发仍会发送 BTM 广播)。该任务所服务的工作完成后,请执行 disable——不要因为觉得停止后无法恢复,就让一个已失去用途的周期性任务继续运行:disable 完全可逆(enable + bootstrap),而 plist 可以保留在磁盘上,作为重新启用的途径。
永久停用任务
彻底退役一整套任务(任务 + plist + 工具),与停止单个任务是不同的操作;可以放心彻底处理:除最后一步外,其余步骤都可恢复。
- 停止并留存记录。 对已加载的任务执行
bootout;已禁用且处于空闲状态的任务没有正在运行的内容。读取一次 plist(plutil -p),并将其内容保存在会话记录中——其中列出了即将移除的工具路径、日志路径和计划。 - 从
~/Library/LaunchAgents/中移除 plist。 plist 删除后,该标签遗留的launchctl disable覆盖便不再生效;无需处理它。 - 删除工具前,确认不存在任何引用:在所属仓库中搜索该标签和工具名称。若命中项仅位于工具自身目录中,就意味着没有其他内容导入、调度或记录它。
- 通过仓库的常规 PR 流程删除工具——Git 历史就是备份。合并完成后,从检出目录中移除可重新生成的残留文件(
__pycache__)。 - 默认保留数据/日志目录(无论 plist 指向何处,例如
~/.local/share/<tool>/):该目录很小,其中保留了恢复任务所需的配置;删除它是唯一不可逆的步骤——应明确征求是否删除,而不是悄悄清理。
2026-10-20 测量(wechat-print 退役):一个每分钟触发的
StartCalendarInterval 任务在六天内除了输出失败日志,什么也没做,却仍持续产生 § Deploy-9 BTM 开销。
故障排查速查表
| 症状 | 首先检查 |
|---|---|
| “我退出应用后,它又重新启动了” | URL 方案/open 调用缺少进程存活门控(条款 4) |
| “它每隔几分钟就重复同一项修复” | 未设置耗尽轮次后的冷却时间(条款 3);另请检查阶梯的失败路径是否会重置其失效计数器 |
| “实际发生故障时,它仍报告运行正常” | 健康检查只验证其探测的路径——一个探测通过 ≠ 所有平面都健康(添加对第二个平面的探测) |
| “bootout 没生效/它又回来了” | 确认准确的域/服务目标,以及是谁再次引导了它;如需持久停止,请参阅 § 停止语义 |
| 静默无运行 | 缺少 StandardErrorPath → 故障不可见;然后运行 log show --predicate 'process == "launchd"' --last 15m |
| “日志 mtime 是最新的”,但任务实际上失败了 | out.log 全绿 不代表 健康——失败的运行可能不会写入任何内容(set -e + 仅在成功时打印已验证内容),所以上次成功时间戳会永远保持最新。应判断失败路径(err.log/上次退出状态),而不是成功侧的新鲜度(模式 7) |
bootout+bootstrap 后,“数据文件里有一条最近的记录”并不能证明新任务正常运行 | 旧任务的最后一条记录可能落在你的新鲜度窗口内(真实案例,2026-10-20:一次探测转换通过了 75 s 新鲜度检查,但新任务的每次迭代都因其身份检查而被拒绝,持续了 75 min)。应基于新任务进行验证:时间戳晚于新 PID 启动时间的记录,或每次迭代的状态记录;state=running 绝不能证明采集器有写入数据 |
| 交互运行正常,在 launchd 下失败 | TCC/FDA 授权给了错误的解释器;ProgramArguments 中存在 PATH 假设 |
| 随着历史记录增长,观测速度变慢 | 使用 references/probe-cost.md 审查冷启动、热启动和增量工作;缓存字节并不能限制解析或查询的开销 |
各条款的详细信息和经过脱敏的真实案例:references/quiet-watchdog-patterns.md。