我给自己写的 agent 定过一条规则:存活不等于有用,产物的时间戳比 PID 更可信。一个被加载、在监管者眼里很健康、却什么都没产出的任务,才是真正值得抓的失败。检测 exit 127 谁都会。
然后我把这条规则套到自己的机器上,得到了一个错误的答案。
![]()
我看到了什么
三个监听任务。launchctl 列得出来,pgrep -f sentinel_listen.py 返回了 PID。它们的日志文件:sentinel_listen.log 8103 字节,mtime 2026-08-11;courtside_listen.log 8127 字节,mtime 2026-08-11;eye_listen.log 8149 字节,mtime 2026-08-11。最后写入都是 8 月 11 日。按我自己那条规则,这就是三个穿着健康外衣的死任务。那句话我已经写好了。
先说清这三个东西是什么,省得别人去猜。它们是三个很小的 Telegram 轮询器,每个 148 到 243 行。等一条消息,回一条消息。它们不是我在做的任何东西里有意思的部分,下面的失败也不是它们的失败,是我检查它们的方式的失败。我拿它们当标本,恰恰因为它们足够简单。如果一个健康检查连一个 148 行的轮询循环都搞不对,更难的东西它更搞不对。
脚本实际在做什么
在发布那个判断之前,我打开了真正写这个文件的代码。里面有三个 print() 调用:
scripts/sentinel_listen.py:120 print("SENTINEL LISTEN: no rail configured ...")
scripts/sentinel_listen.py:128 print("SENTINEL LISTEN: awake, polling every 20s ...")
scripts/sentinel_listen.py:142 print(f"SENTINEL LISTEN error: {type(exc).__name__}: {exc}")
启动、横幅、错误。courtside 和 eye 是同样的形状,分别在 :144/:152/:175 和 :198/:201/:236。
成功路径什么都不打印。没有"仍在轮询"的心跳。正常路径下它调用 getUpdates 做 25 秒长轮询,写一个小状态文件,睡 20 秒,一句话都不说。所以一次安静的通过是轮询加 20 秒,不是 20 秒。捕获到异常后睡 30 秒,再走同样的睡眠。间隔在各次通过之间并不均匀。
所以日志里的沉默不等于任务停了。这个文件只在启动时和被捕获的异常时写入,而我把它的沉默读成了死亡。
我没法再往下推,原因很重要。这三个任务运行时没加 -u,stdout 是块缓冲的。一分钟前打印的异常可能还躺在缓冲区里,没落到磁盘上。文件没变不能证明没发生错误,只能证明文件没被修改过。这是两句不同的话,而第二句才是我能支持的。
那 8103 字节是一场旧的错误风暴:一个横幅加八十个被捕获的异常,76 个 URLError,3 个 timeout,1 个 HTTPError。这是文件记录下来的东西。它此后没能记录什么,是文件无法回答自己的问题。
真正在动的那个文件
就在我检查这些任务是不是死了的时候,它们在写东西。我看着三个状态文件往前走。以 sentinel 为例:
$ stat -f '%Sm %N' -t '%H:%M:%S' scripts/sentinel_listen_state.json
11:13:03 sentinel_listen_state.json
$ stat -f '%Sm %N' -t '%H:%M:%S' scripts/sentinel_listen_state.json
11:15:19 sentinel_listen_state.json
日志从 8 月 11 日起就没被碰过,状态文件却是几秒前的。两者属于同一个监听任务,但只有一个被设计成在普通循环迭代中变动,而且它们在不同的目录里。我盯的是 agent_outputs/,活着的文件在 scripts/。
我挑了一个只在启动和失败时才动的产物。那个文件里最后一条启动行来自 8 月 11 日。进程本身在 9 月 18 日回来了,而在 9 月 22 日那次检查时,那次重启发出的横幅还没到达磁盘上的日志文件。
更好的信号也不是证据
这里我得第二次拦住自己,因为"改看状态文件"是显而易见的教训,而它也不完全对。
这是 courtside 循环的末尾:
while True: # :153
try: # :154
for upd in updates.get("result", []): # :156 (body elided)
...
STATE_PATH.write_text(json.dumps(state)) # :173 only after a handled message
except Exception as exc: # :174
print(f"COURTSIDE LISTEN error: {type(exc).__name__}: {exc}")
time.sleep(30)
STATE_PATH.write_text(json.dumps(state)) # :177 every pass, either way
time.sleep(POLL_SECONDS) # :178
有两次写入。:173 那次在 try 里面,处理完一条消息之后。:177 那次在循环层级,在 except 之外,所以每次通过都会执行,包括刚刚捕获异常并睡了一觉的那次。
第二次写入才是让 courtside 时间戳保持新鲜的那个,eye 也是同样的构造。所以对这两个来说,状态文件 mtime 新鲜证明循环在转。它不证明某次轮询成功了,也分不出好的通过和失败的通过。
sentinel 不一样。它没有循环层级的写入,只有 :140,在 try 里面、getUpdates 返回之后的那一次。所以 sentinel 的时间戳是比另外两个更强的信号,它表明 API 调用返回了可解析的 JSON,循环走到了写入那一步。它不单独记录 API 层面的成功、消息是否到达、回复是否送达。一次成功的空轮询是正常的。三个我一直当成一行处理的任务,其实是三个不同的写入点。
而内容也不是一个故事。我检查了全部三个:
sentinel offset 0 history 0 13 bytes
courtside offset 51848716 history 4 545 bytes
eye offset 63652330 history 8 2988 bytes
我本来看了 sentinel,看到 {"offset": 0},正要写下三个都从未消费过消息。其中两个持有非零的已保存游标和非空的历史。三个从 launchctl 看一模一样的任务,处在三种不同的状态里,而我知道这件事的唯一原因,是我打开了全部三个,而不是一个。
连 sentinel 的那个零也比它看起来的要小。它是此刻保存的游标,不是那个任务有史以来做过的一切的历史。
每样东西实际能确立什么
最后两行才是重点。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.