我踩过最难受的一次不是任务报错,而是它根本没报错。
早上九点该出的东西没出,打开一看:那条任务昨天的执行记录是空的,没有失败、没有重试、没有告警。翻日志什么都没有。你不知道它是没被触发,还是触发了、跑到一半卡在某个你看不见的地方。
这类问题有个共同点:在任务里加 try/except 没用,加重试也没用——因为它卡的不是代码逻辑,是代码和你这台机器之间的那层环境。
顺带说个热点:10 月 28 日 WorkBuddy 上线了「远程控制」,手机上能看电脑桌面的实时画面并直接接管【实证,二手来源:钛媒体/腾讯网 10-28 报道】;据 Pandaily 同日报道,官方称远程控制走流量、不消耗积分。这功能解决的是「卡住之后我能接手」。但更好的做法是让它根本不卡——下面这份清单就是干这个的。
定时任务出问题,先别查调度器,先体检「中断点」。
我把它分成两类,排查顺序完全不同:
症状 | 大概率原因 | 先看哪几项 |
|---|---|---|
根本没跑(无执行记录) | 调度器没登记 / 机器睡了 / 时区错位 | 睡眠、调度器登记、时区 |
跑了但产物残缺或卡死 | PATH 变了 / sudo 要密码 / 有交互提示 / 盘满了 | PATH、sudo 免密、TTY、磁盘 |
一句话边界:这套体检只管「环境会不会把任务卡住」,不管业务逻辑对不对。 业务正确性得另一套校验(比如产物行数、金额勾稽),别指望它。
看两件事就够:
macOS 上 cron 的默认 PATH 是 /usr/bin:/bin。你在终端里装的东西,多半在 /opt/homebrew/bin 或自己的 ~/.xxx/bin 下,cron 全看不见。
# 终端里(你在的环境)
command -v ffmpeg
# 模拟 cron 的环境
PATH=/usr/bin:/bin command -v ffmpeg两条输出不一样,就是中招了。
无人值守环境下,stdin 不是终端。任何 input()、密码提示、确认框都会让任务安静地挂在那里直到超时。
sudo -n true; echo "退出码=$?"
# 返回 1 = 需要输密码 = 无人值守必卡-n 是非交互模式,不会真的弹密码框,可以放心跑。
pmset -g | grep -E "^(sleep|disksleep|standby|hibernatemode)"
df -k / # 根分区已用百分比
date "+%Z %z" # cron 用的是系统本地时区sleep 不等于 0,任务跑到一半机器睡过去,第二天你看到的就是一条残缺记录。要包一层:caffeinate -s python3 task.py。
「我以为它在跑」和「它真的登记了」是两回事:
crontab -l
ls ~/Library/LaunchAgents | wc -lcron 和 LaunchAgent 是两套东西,只改一边不生效。
完整脚本放文末附录,也可以直接用同目录 unattended_probe.py(9 项,零第三方依赖,Python 3.9+):
python3 unattended_probe.py --check
python3 unattended_probe.py --check --job daily-report # 确认目标任务登记没
python3 unattended_probe.py --check --cmd ffmpeg --cmd ollama
python3 unattended_probe.py --demo # 对照组,强烈建议先看这个
python3 unattended_probe.py --check --json # 机器可读--demo 看一次对照组这一步是我写这个脚本最大的收获。同一个任务、两种写法、两种 PATH,跑 4 次:
python3 unattended_probe.py --demonaive 写法(ffmpeg ... | head -1 || echo "找不到")在 cron 环境下:退出码 0,没有任何报错,但 ffmpeg 那一步压根没执行,产物是残缺的,你还以为成功了。
strict 写法(先 command -v 探一次,再决定跑不跑)同一环境:退出码 1,立刻暴露。
脚本有 FAIL 项就退出码 1,可以直接前置:
python3 unattended_probe.py --check --job daily-report || {
echo "环境中检未通过,任务终止"; exit 1;
}前面 8 步能挡掉绝大多数。剩下的「必须人点一下」的活儿(登录态过期、授权弹窗、验证码),才是远程控制的用武之地——它是兜底,不是替代体检。
以下全部为本机真实执行结果(macOS 27.0.1 / Python 3.14.3,2026-10-29 跑),路径中的用户名已替换为 ~:
检查项 | 级别 | 实测值 |
|---|---|---|
PATH(cron 环境) | FAIL |
|
TTY(交互提示) | OK |
|
sudo 免密 | FAIL |
|
睡眠/休眠 | OK |
|
磁盘空间 | OK | 根分区已用 2% |
调度器登记 | WARN | cron 条目 0 条,LaunchAgents 13 个(未指定 |
系统权限(TCC) | WARN | 两个常见路径下均未找到可读的 |
网络出口 | OK |
|
时区 | OK | Python 视为 CST (UTC+08:00),系统 |
汇总:9 项,OK 5 / WARN 2 / FAIL 2,退出码 1。
--demo 对照跑 4 次的真实结果:
写法 | 环境 | 退出码 | ffmpeg 那一步 |
|---|---|---|---|
naive | 当前 PATH | 0 | 执行,输出 |
naive | cron 默认 PATH | 0 | 静默跳过,无任何报错 |
strict | 当前 PATH | 0 | 执行 |
strict | cron 默认 PATH | 1 | 显式报 |
那个 naive + cron = 退出码 0 但没干活 的格子,就是这篇文章存在的理由。
耗时:9 项体检全程约 1 秒内(主要开销是 pmset、df、crontab 三个外部命令各一次)。
坑 1:管道会把错误吞掉(本次最大发现)。
cmd | head -1 || echo "失败" 里的 || 判的是 head 的退出码,不是 cmd 的。cmd 不存在时 head 照样成功,|| 永远不触发。改成先 command -v 探一次,或者直接判 ${PIPESTATUS[0]}。我第一版 demo 脚本就栽在这,跑出来才发现「咦,B 组怎么没有报错」。
坑 2:探测不到 ≠ 没问题。
TCC 权限(辅助功能 / 屏幕录制 / 完全磁盘访问)我试了两个常见路径都没读到 TCC.db,脚本只能报 WARN 并提示人工核对。macOS 新版本路径会变,脚本不猜——宁可报「无法确认」,也不要报「一切正常」骗你。
坑 3:报告本身就是泄密源。
体检输出含本机用户名、LaunchAgent 文件列表。脚本默认只报 LaunchAgent 数量不报文件名,外发前还要把 $HOME 替换掉。这条是上一篇文章里学到的,这次直接写进默认行为。
坑 4:sudo -n true 必须带 -n。
不带就是真的去弹密码框了,在无人值守环境里反而制造一次卡死。
边界(什么情况下这招不好使):
定时任务出问题,八成不在调度器,在「你不在电脑前时没人替它答那一句」。体检 9 项、退出码做门禁、--demo 先看一次对照组,能挡掉绝大部分。剩下真需要人出手的,再交给远程控制兜底。
#WorkBuddy# 定时任务「没跑」和「跑一半卡住」,多半不是调度器的问题:9 个中断点体检清单(附 macOS 零依赖脚本)完整 9 项版见 草稿/assets/unattended_probe.py。下面这版 120 行左右,结论与完整版交叉验证一致(同为 FAIL 2 项)。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
unattended_probe_mini.py —— 无人值守任务中断点体检器(最小可用版,5 项)
完整版 9 项见同目录 unattended_probe.py。零第三方依赖,macOS / Python 3.9+。
用法:python3 unattended_probe_mini.py
退出码:0=无 FAIL;1=至少一项 FAIL(可直接做流水线门禁)
"""
import os, subprocess, sys, time
CRON_PATH = "/usr/bin:/bin" # macOS 上 cron 的默认 PATH
CHECKS = ["python3", "git", "curl", "ffmpeg", "ollama", "node"]
def run(cmd, timeout=5.0, env=None):
try:
p = subprocess.run(cmd, shell=True, capture_output=True,
text=True, timeout=timeout, env=env)
return p.returncode, (p.stdout + p.stderr).strip()
except subprocess.TimeoutExpired:
return 124, "TIMEOUT"
except Exception as e: # noqa: BLE001
return 127, "%s: %s" % (type(e).__name__, e)
def which(cmd, path):
rc, out = run("command -v " + cmd, env={"PATH": path})
return out if rc == 0 and out else None
def main():
res = []
# 1 PATH:当前找得到、cron 找不到的命令
cur = os.environ.get("PATH", "")
missing = [c for c in CHECKS if which(c, cur) and not which(c, CRON_PATH)]
res.append(("PATH(cron)", "FAIL" if missing else "OK",
("cron 下找不到:" + ", ".join(missing)) if missing
else "%d 个命令在 %s 下均可解析" % (len(CHECKS), CRON_PATH)))
# 2 TTY:无人值守环境下任何交互提示都会卡死
atty = sys.stdin.isatty()
res.append(("TTY", "WARN" if atty else "OK",
"isatty=%s(True=有人盯,False=无人值守)" % atty))
# 3 sudo 免密:无人值守下必然卡在密码提示
rc, out = run("sudo -n true")
res.append(("sudo免密", "OK" if rc == 0 else "FAIL",
"sudo -n true rc=%d %s" % (rc, "" if rc == 0 else out[:40])))
# 4 睡眠:sleep 非 0 就可能在任务中途睡过去
rc, out = run("pmset -g")
kv = {}
for line in out.splitlines():
p = line.split()
if len(p) >= 2:
kv[p[0]] = p[1]
try:
mins = int(kv.get("sleep", "-1"))
lvl, val = ("OK", "sleep=0(不睡)") if mins == 0 else ("WARN", "sleep=%s 分钟" % mins)
except ValueError:
lvl, val = "WARN", "sleep=%s(非数值)" % kv.get("sleep", "?")
res.append(("睡眠/休眠", lvl, val))
# 5 磁盘:产出写不进去,任务会在最后一步失败
rc, out = run("df -k /")
tail = out.splitlines()[-1].split()
try:
pct = int(tail[4].rstrip("%"))
except (ValueError, IndexError):
pct = -1
res.append(("磁盘空间",
"FAIL" if pct >= 90 else ("WARN" if pct < 0 else "OK"),
"根分区已用 %s%%" % pct))
print("%-14s %-6s %s" % ("检查项", "级别", "实测值"))
print("-" * 60)
for name, lvl, val in res:
print("%-14s %-6s %s" % (name, lvl, val))
print("-" * 60)
nf = sum(1 for r in res if r[1] == "FAIL")
print("FAIL %d 项" % nf)
return 1 if nf else 0
if __name__ == "__main__":
sys.exit(main())本机实测输出(最小可用版):
检查项 级别 实测值
------------------------------------------------------------
PATH(cron) FAIL cron 下找不到:ffmpeg, ollama, node
TTY OK isatty=False(True=有人盯,False=无人值守)
sudo免密 FAIL sudo -n true rc=1 sudo: a password is required
睡眠/休眠 OK sleep=0(不睡)
磁盘空间 OK 根分区已用 2%
------------------------------------------------------------
FAIL 2 项原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。