☰
无人值守自动化的三种静默死法:幽灵在途、未定义 provider、晚建的定时任务
2026/10/16 6:58:57 网站建设 项目流程
三个案例全部来自同一台 Windows 机器上真实运行的一整套无人值守设施(计划任务守护 + 定时节拍 + 外部 CLI 执行器 + 会话唤醒器)。命令与标识做了脱敏,形态与判据保持原样。

一、现场:仪表盘全绿,但它已经一整天没干过活

凌晨两点,我把这套设施的状态过了一遍:

  • 底座心跳:在跳,序号连续;
  • 计划任务:State=Ready,LastTaskResult=0;
  • 日志文件:体积在涨;
  • 账本:数字正常。

唯一不正常的地方是——它已经连续一天没有成功建出过一次新会话了。

而三种故障的日志里,没有一个error,没有一个非零退出码,没有一次异常。

这类故障最贵的地方不在修复,而在发现:所有仪表盘都在说"我很正常"。

二、死法一:一个没有结果文件的"在途任务",把闸门永久顶住

机制

唤醒器在起新会话之前有一道防重入闸:

存在 task-<ID>-dispatch.json 且不存在 task-<ID>-result.json ⇒ 判断"有在途派单" ⇒ skip(不重复起)

这个闸的动机完全正确:无人值守场景下最怕的就是重复派单、并发撞车。

现场

temp/agent-bus/task-DK1015-dispatch.json ← 存在(派单时写的) temp/agent-bus/task-DK1015-result.json ← 永远没出现(子进程早就死了)

派单时记下的 child pid,在进程表里已经查不到了。

为什么难发现

因为闸门本身是"静默"的:它的动作是skip,退出码 0,只在一行结构化日志里留一句guard=inflight。

一天下来,这行日志重复了几百次,每次都完全合法。你不会去数它——除非你先知道"今天一次都没建出会话"。

修法

任何"存在即阻塞"的判据,必须同时配两样东西:

if dispatch_exists and not result_exists: stale = (now - mtime) > TTL # ① 超期 orphan = not pid_alive(child_pid) # ② 活性:记录的进程还在不在 if stale or orphan: log("inflight-ghost-released", ...) # ③ 放行必须留痕 allow_wake()

第三条容易被省,但它恰恰是关键:"我判定它是幽灵然后放行了"这件事,本身必须留下痕迹,否则你永远分不清"没有幽灵"和"幽灵被放行了"。

一句话:在途闸不能只看文件在不在,要看它还是不是活的。

三、死法二:模型路由指向一个不存在的 provider

现场

这一次,唤醒器判定成功了,会话也建出来了。但第一轮就死:

DeepSeek Messages transport failed

根因

配置里写的 provider 名,在整个 profile 的 provider 定义表里不存在。

换链路的时候,我只改了模型名,没有对拍 provider 定义——而这条链路恰好是当天唯一被选中的那条。

为什么难发现

因为从外面看,"唤醒成功"这件事已经发生了:会话文件建出来了、账上"唤醒次数 +1"、时间线完整。

于是很自然地把它归到"模型服务那边抽风",而实际上它是一行本地配置错误。

修法:改配置前先对拍真源

① 真源=profile 里的 providers 定义;配置里的 provider 值必须能在其中**逐字命中** ② 对拍要落成一条命令,改完立刻跑,不靠记忆 ③ 把这条对拍写进派单脚本的**前置检查**里,检查不过就不派

附带:怎么证明"新会话真的跑通了"

这一点值得单独讲,因为"建出来了"与"跑通了"需要的证据完全不同:

  • 建出来了⇒ 看会话目录 + 唤醒器台账里那一条action=wake;
  • 跑通了⇒ 必须打开会话日志,读第一拍请求头与第一条 assistant 消息。

会话日志(session.vN.jsonl.zstd)有一个很容易踩的坑:它是多帧 zstd 流(一个 header 帧 + 每批追加一帧)。一次性zstdDecompressSync只吐出第一帧——你会得到一个"只有一行、看起来刚建出来、什么都没发生"的文件,然后误判为"会话是空的"。

正确做法是按 zstd frame magic(28 B5 2F FD)扫帧、逐帧解:

const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); function decodeFrames(buf) { const out = []; for (let at = buf.indexOf(MAGIC, 0); at !== -1; at = buf.indexOf(MAGIC, at + 4)) { try { const t = zlib.zstdDecompressSync(buf.subarray(at)).toString('utf8'); if (t) out.push(t); } catch { /* 压缩体内部的伪 magic:解不动就跳过 */ } } return out.join(''); }

解出来之后,只要两行就能自证:

request/header → config.provider / config.model (这一拍实际发给谁) assistant/message → source.kind=model, provider/model (这一拍实际由谁回答)

我这次拿到的正是:provider=qwen-token-plan, model=deepseek-v4.1-flash,且紧随其后有一条turn=1 step=1的 assistant 消息,带 2 个工具调用。

——"我配好了"和"它真跑了"之间,隔着一整条证据链。

四、死法三:晚于 00:00 建立的定时任务,当天永不触发

现场

TaskName : App-DailyKickoff State : Ready LastRunTime : 1999/11/30 0:00:00 ← 从未运行 LastTaskResult : 267011 ← 0x00041303 = "任务尚未运行" NextRunTime : 次日 00:00

根因

这个任务是当天 00:13注册的,触发器是"每天 00:00"。

Windows 的语义是"等下一个 00:00",它不会因为"今天那一次还没跑过"就补跑。同一种静默还发生在另一个场景:机器在 00:00 处于关机或睡眠态,那一次也就静静地过去了(除非显式开启"错过则尽快补跑"类的设置)。

为什么难发现

任务存在、状态 Ready、时间表看起来完全正确、NextRunTime也合理。唯一说实话的是LastTaskResult那个数字——而它长得像个随机数(267011),没人会专门去看它。

再一次:"配置正确"与"跑过了"是两件事。

修法:把日任务下沉到底座内部 tick

既然底座每 15 分钟就有一拍进程起来,就让那一拍自己判断"今天跑过没有":

state, why = kickoff_state_today(date) # done / in-progress / none if state == "none": spawn_detached("daily_kickoff.py") # 不阻塞本拍

好处是它顺手解决了"机器 00:00 关机"的情况:第一个跨日之后的 tick,无论几点,都会把当天的日任务补上。而且这条路径是"进程级重载"的(每拍新起一个进程),改完当拍即生效,不需要重启任何常驻进程。

顺带踩到的第二个坑:双入口 ⇒ 双跑

改完之后,日任务就有了两个入口:

  1. 00:00 的 Windows 计划任务;
  2. 00:02 的底座 tick。

而日任务的"完成"标记是结尾才写的(它自己还要唤醒几个 GUI 端,最坏情况几分钟)。于是约 00:02 那一拍会判"今天还没跑过",再起一跑⇒ 双跑 ⇒ 界面被点两次。

修法是给两个入口一个共享的幂等门:

起跑即写一行 START(带日期) 闸门判序:DONE 优先 → START 且在 TTL 内 ⇒ "在跑,别再来" → 否则才投

这里有一个必须点名的对称性:如果没有 TTL,这个 START 门就会退化成死法一——一个崩溃后残留的 START 行会把当天的日任务永久顶住。

同一个判据,两种用法:TTL 是解药,省略 TTL 就是毒药。

五、三个死法真正的共性

把三张现场并排看,它们没有任何技术上的共同点:

死法表面信号真正的读数
幽灵在途闸门日志guard=inflight(合法)子进程 pid 是否存活
未定义 provider会话已创建、"唤醒 +1"首拍请求头的 provider/model
晚建的定时任务State=Ready、时间表正确LastTaskResult是不是 0

共同点只有一条,而且不在代码里:

我把"推断"当成了"读数"。
  • 文件存在 ⇒ 推断"派单在途"(没查进程);
  • 会话建出来了 ⇒ 推断"唤醒链好了"(没查首拍);
  • 任务注册成功 ⇒ 推断"零点会跑"(没查触发语义与 LastResult)。

每一条推断单独看都很合理——这正是它们能活一整天的原因。

六、一张可复制的检查清单

  1. "存在即阻塞"的判据,必须配 TTL和活性检查;并且放行、超期、残牌这三种结果都要留痕(否则你分不清"没有幽灵"和"幽灵被放行")。
  2. 配置改动:每一条都配一句对拍命令,改完立刻跑;对拍不通过就不派单。
  3. "建成"与"跑通"分开断言:建会话看目录与台账,跑通看第一拍请求头 + 第一条 assistant。
  4. 定时任务的验收只认LastTaskResult:0 才是跑过;267011是"从未运行";"时间表看起来对"不是证据。
  5. 引入第二个入口前先问:两个入口会不会同时进来?会,就给它们一个共享的 START/DONE 幂等门,并给这个门配 TTL。
  6. 多帧压缩日志要按帧解:一个"只解出第一帧"的解析器会把"有内容"读成"空会话"。
  7. 别让"看起来正常"当验收信号——它是这类故障唯一有效的伪装。

七、结语

这三种死法加起来,浪费的是一整天,而修复本身每条不超过二十行。

真正花时间的部分,是说服自己"仪表盘全绿"不等于"它在干活"。

如果你的无人值守设施只有一个信号源,那么你测的不是它的健康,而是那个信号源的健康。多出来的每一个独立读数——进程是否存在、请求头里写的是什么、任务的 LastResult 是不是 0——都不是洁癖,它们是你唯一的观测面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询