三个案例全部来自同一台 Windows 机器上真实运行的一整套无人值守设施计划任务守护 定时节拍 外部 CLI 执行器 会话唤醒器。命令与标识做了脱敏形态与判据保持原样。一、现场仪表盘全绿但它已经一整天没干过活凌晨两点我把这套设施的状态过了一遍底座心跳在跳序号连续计划任务StateReadyLastTaskResult0日志文件体积在涨账本数字正常。唯一不正常的地方是——它已经连续一天没有成功建出过一次新会话了。而三种故障的日志里没有一个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只在一行结构化日志里留一句guardinflight。一天下来这行日志重复了几百次每次都完全合法。你不会去数它——除非你先知道今天一次都没建出会话。修法任何存在即阻塞的判据必须同时配两样东西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 值必须能在其中**逐字命中** ② 对拍要落成一条命令改完立刻跑不靠记忆 ③ 把这条对拍写进派单脚本的**前置检查**里检查不过就不派附带怎么证明新会话真的跑通了这一点值得单独讲因为建出来了与跑通了需要的证据完全不同建出来了⇒ 看会话目录 唤醒器台账里那一条actionwake跑通了⇒ 必须打开会话日志读第一拍请求头与第一条 assistant 消息。会话日志session.vN.jsonl.zstd有一个很容易踩的坑它是多帧 zstd 流一个 header 帧 每批追加一帧。一次性zstdDecompressSync只吐出第一帧——你会得到一个只有一行、看起来刚建出来、什么都没发生的文件然后误判为会话是空的。正确做法是按 zstd frame magic28 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.kindmodel, provider/model 这一拍实际由谁回答我这次拿到的正是providerqwen-token-plan, modeldeepseek-v4.1-flash且紧随其后有一条turn1 step1的 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无论几点都会把当天的日任务补上。而且这条路径是进程级重载的每拍新起一个进程改完当拍即生效不需要重启任何常驻进程。顺带踩到的第二个坑双入口 ⇒ 双跑改完之后日任务就有了两个入口00:00 的 Windows 计划任务00:02 的底座 tick。而日任务的完成标记是结尾才写的它自己还要唤醒几个 GUI 端最坏情况几分钟。于是约 00:02 那一拍会判今天还没跑过再起一跑⇒ 双跑 ⇒ 界面被点两次。修法是给两个入口一个共享的幂等门起跑即写一行 START带日期 闸门判序DONE 优先 → START 且在 TTL 内 ⇒ 在跑别再来 → 否则才投这里有一个必须点名的对称性如果没有 TTL这个 START 门就会退化成死法一——一个崩溃后残留的 START 行会把当天的日任务永久顶住。同一个判据两种用法TTL 是解药省略 TTL 就是毒药。五、三个死法真正的共性把三张现场并排看它们没有任何技术上的共同点死法表面信号真正的读数幽灵在途闸门日志guardinflight合法子进程 pid 是否存活未定义 provider会话已创建、唤醒 1首拍请求头的 provider/model晚建的定时任务StateReady、时间表正确LastTaskResult是不是 0共同点只有一条而且不在代码里我把推断当成了读数。文件存在 ⇒ 推断派单在途没查进程会话建出来了 ⇒ 推断唤醒链好了没查首拍任务注册成功 ⇒ 推断零点会跑没查触发语义与 LastResult。每一条推断单独看都很合理——这正是它们能活一整天的原因。六、一张可复制的检查清单存在即阻塞的判据必须配 TTL和活性检查并且放行、超期、残牌这三种结果都要留痕否则你分不清没有幽灵和幽灵被放行。配置改动每一条都配一句对拍命令改完立刻跑对拍不通过就不派单。建成与跑通分开断言建会话看目录与台账跑通看第一拍请求头 第一条 assistant。定时任务的验收只认LastTaskResult0 才是跑过267011是从未运行时间表看起来对不是证据。引入第二个入口前先问两个入口会不会同时进来会就给它们一个共享的 START/DONE 幂等门并给这个门配 TTL。多帧压缩日志要按帧解一个只解出第一帧的解析器会把有内容读成空会话。别让看起来正常当验收信号——它是这类故障唯一有效的伪装。七、结语这三种死法加起来浪费的是一整天而修复本身每条不超过二十行。真正花时间的部分是说服自己仪表盘全绿不等于它在干活。如果你的无人值守设施只有一个信号源那么你测的不是它的健康而是那个信号源的健康。多出来的每一个独立读数——进程是否存在、请求头里写的是什么、任务的 LastResult 是不是 0——都不是洁癖它们是你唯一的观测面。