# 唤醒 · 「会话空闲 ⇒ 宿主 spawn idle 钩子」坐实(2026-09-30 19:0x) > 状态:**成立 ✅ 端到端坐实**(拿到全天唯一一次真触发) > 线:机制线 · 唤醒机制探索 | 域:`ai1net-dsh-server/` | 会话:`706d2476`(19:00 起的接续会话) > 上游:`接续包_唤醒机制探索_20260930.md`(本棒执行其 §1 唯一目标) > 🔴 本文**修正**上一棒两条结论(见 §6)——不修正会把假事实带给后续所有棒。 --- ## 0. 一句话结论 **成立。**「会话空闲满 60 秒 ⇒ 宿主自己去 spawn `Notification` 钩子(`idle_prompt`)」今天 **18:55:00.210 拿到全天唯一一次真触发**,判据端到端达成。 但"空闲"不是"没收到用户消息 60 秒",而是**两道隐形闸门同时满足**: 1. 会话状态机 = `idle`(**每次 `addHistory` 都重置 60 s 计时器** ⇒ 回合进行中永不计时) 2. 该会话**没有 pending/running 的后台任务**(`hasLiveBackgroundTask()`) 🔴 **第 2 条把我们瞒了 6 小时 20 分**:一个 12:17:45 起的后台任务一直没退出,把 idle 钩子全程压死,且**闸门拦下是"正常返回"、宿主一个字节都不记** ⇒ 现场看起来就是"什么都没发生"。上一棒据此误判成"项目级配置对已运行会话不生效"。 --- ## 1. 判据与达成(本棒验收) | 判据 | 要求 | 实测 | 判定 | |---|---|---|---| | 探针出现**宿主新增**行 | 非手工自测 | `tmp/_idle_hook_probe.jsonl` 第 2 行:`ts=2026-09-30 18:55:00`、`session_id=6ecf6d98-…`、`transcript_path` 指向真实会话、`pid/ppid` 真实 | ✅ | | 时间戳落在**真实 >60 s 空闲窗**内 | — | 该会话 `AGENT_ENDED→idle` @18:54:00,**至今未再 resume**(窗口 6 分钟以上) | ✅ | | 该行**不是手工写** | 排除自测 | ① 该会话 transcript **最后一条 = 18:54:00 的助手收官消息**,其后**零工具调用**(不可能手工喂 stdin)② 该行 env 里**没有** `CODEBUDDY_TOOL_CALL_ID`(17:51:51 那行手工自测**有**)⇒ 不是工具调用起的 | ✅ | --- ## 2. 证据链(5 条独立互证,任一条单独都指同一结论) | # | 证据 | 原文 / 读数 | |---|---|---| | 1 | **宿主 spawn 记录(决定性)** | `E:/ProgramData/.workbuddy/logs/2026-09-30/ai1net-dsh-server__e476dc05d23a01f725b368d606f4ff0f.log:146869`:`[2026/9/30 18:55:00.210] [HookExecutor] spawn pid=54964 shell=…bash.exe timeout=8000ms cmd="…python.exe" "…/.workbuddy/tools/idle-hook-probe.py"` —— **全天 999 条 `HookExecutor spawn` 里含本探针的只有这 1 条** | | 2 | **探针落行** | 第 2 行 `ts=18:55:00`,`gateway_password_present=true`、`session_id=6ecf6d98-1308-4218-8111-50a71fcb7201` | | 3 | **60.0 s 精确对齐** | 该会话最后一条 `[addHistory] COMPLETED` = **18:54:00.193** ⇒ 计时器装于此刻 ⇒ 到期 **18:55:00.19**,与宿主 spawn(.210)/探针落行(18:55:00)**同一秒**。源码:`resetIdleTimer` 里 `setTimeout(…, 6e4)` 收在 `addHistory` 末尾 | | 4 | **不是手工/不是别的会话** | 见 §1 第 3 行;且只有 hook 执行器会带着**真实 transcript_path** 与 session_id、又不带 `TOOL_CALL_ID` | | 5 | **真空闲窗** | `[SessionRunStateMachine]` 该会话最后迁移 = `AGENT_ENDED | from=model_done | to=idle`,此后无任何迁移 | --- ## 3. 机制全貌(源码 + 实测校正) ``` addHistory(任何历史写入) ──► resetIdleTimer(session) // 每次都重装 │ ▼ setTimeout(60 000) ┌───────────────────────────────┐ │ !stateMachine.isIdle(session) │──是──► 重装表,再等 60 s(⛔ 无日志) │ OR │ │ hasLiveBackgroundTask() │ └───────────────┬───────────────┘ │ 否(两道闸门同时通过) ▼ sessionHookManager.executeNotificationHooks(session, "CodeBuddy is waiting for your input", IDLE_PROMPT) ▼ 宿主 [HookExecutor] spawn(bash + 用户钩子命令) ⚠️ timeout=8000ms | 钩子自带 CODEBUDDY_GATEWAY_PASSWORD ``` 要点(全部实测/源码可查): - **计时器装点 = 每次 `addHistory` 完成**(不只是回合结束)⇒ **回合进行中永不"空闲计时"**;这也解释了"长回合中间没有 idle 钩子"。 - **闸门不通过 ⇒ 只是重装表**,不报错、不写日志 ⇒ **失败完全静默**(这是本次最大的坑)。 - 钩子是**宿主子进程**:自带网关口令(探针实测 `gateway_password_present=true`)、**不占会话、0 token**。 - **已运行会话同样生效**:本会话 12:00 起跑、钩子 17:51 才写进项目级 `.codebuddy/settings.json`,仍然生效 ⇒ **项目级钩子配置对已在跑的会话有效("热生效"成立)**。 --- ## 4. 泳道图(角色 × 阶段) ``` │ ①回合进行中 ②回合结束 ③静默 60s ④闸门判定 ⑤spawn ⑥落地 ──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────── 用户 │ 发消息 ──┐ (离开/不发言) (不在场) │ │ 会话状态机 │ model_streaming AGENT_ENDED→idle idle 保持不变 idle ✔ — — │ tool_executing │ (每次工具/流式都 addHistory) 宿主计时器 │ 装表→被重置 装表 @18:54:00.19 ──60s──► 到期判定 调用 HookExecutor (resetIdleTimer)│(永远等不到 60s) T=18:55:00.19 ┌──────────────┐ │ │ isIdle → 真 │ │ │ ⛔ hasLive- │◄── 6h20m 内这里恒为「真」 │ │ Background- │ ⇒ 重装表、静默 │ │ Task()→问题 │ │ └──────┬───────┘ 钩子执行器 │ — — — — │ 18:55:00.210 spawn (HookExecutor)│ ▼ 探针脚本 │ — — — — 写 tmp/_idle_hook_probe.jsonl │ ts=18:55:00 ✅ 网关 │ — — — — (下一步才用:POST /api/v1/runs 投递) ``` **红格位置**:第 ④ 列「闸门判定」——`hasLiveBackgroundTask()` 恒为真,把后续 ⑤⑥ 全部截断。 --- ## 5. 事故链图(这次实际怎么坏 + 缺席的防线) ``` 12:17:45 collabd 类后台任务(taskId=NDucGY)被起 → 【status=running】 │ │ ⚠️ 它一直没退出:宿主日志到 18:38:13 才 finalize(failed, elapsed=22 827 768 ms = 6h20m19s) ▼ 17:51:30 探针钩子写进项目级 .codebuddy/settings.json(配置本身没问题) │ ▼ 17:56:15 回合结束 → 装表 17:57:15 ◄── 到期:state=idle ✔ / hasLiveBackgroundTask=true ✘ ⇒ 重装表 ⋯⋯ 静默 18:16:52 回合结束 → 装表 18:17:52 ◄── 同上,被压 ⋯⋯ 静默 18:23:22 回合结束 → 装表 18:24:22 ◄── 同上,被压 ⋯⋯ 静默 │ ▼ 18:38:13 僵尸后台任务终于 failed ⇒ 闸门 2 放开 │ 18:54:00 该会话最后一次 addHistory → 装表 18:55:00 ◄── 到期:idle ✔ + 无活后台任务 ✔ ⇒ ★ 宿主 spawn ⇒ 探针落行 ★ │ ▼ (真因被完全掩盖,因为全程 0 条日志) ``` ### 哪几道防线缺席(关键) | # | 缺席的防线 | 后果 | |---|---|---| | ① | **"钩子被闸门拦下"无日志**:宿主只在 `executeNotificationHooks` **抛异常**时 `logger.warn`;闸门不过 = **正常重装表** ⇒ 完全静默 | 现场唯一可见物 = "什么都没发生",把"被压制"误读成"机制不生效" | | ② | **探针只记"来了",不记"没来"** ⇒ 探针无法自证被压制 | 探针全部命中 0 次时无法区分"没触发"与"被压制" | | ③ | **事后取证视角错**:只查了 git 时间线/源码/配置,**没查 `[BashTool] background task` 生命周期**(真因所在) | 得到一个自洽但**错误**的结论(且它被写成"已定死、别再重做取证") | | ④ | **"空闲窗"判据本身错**:上一棒用"没收到用户消息 60 秒"当空闲窗;正确判据是"状态机 idle + 无活后台任务" | 把 17:52–17:55(其实**全程在跑工具**)、18:50–18:52(到期时刻 state=`tool_executing`)都当成了"空闲窗" | ⇒ 教训(治本):**凡"钩子/定时器类机制应当发生却没发生",必须同时取证三件:① 该机制的 spawn/执行日志 ② 它的前置闸门状态 ③ 会压制它的"长活对象"(后台任务/队列/锁)清单**。缺任一件都不许下"机制不成立"的结论。 --- ## 6. 修正上一棒的结论(⛔ 别再把旧的带下去) | 上一棒「已定死」 | 本棒实测 | 处置 | |---|---|---| | ②「**项目级配置对已运行会话不生效**」(实测) | **判错**。配置生效正常;被 `hasLiveBackgroundTask()` 压了 6h20m。本会话 12:00 起跑、配置 17:51 才挂,仍然生效 ⇒ 项目级钩子对已跑会话**确实生效** | 撤销,改写为"闸门压制" | | ②附带的两个"空闲窗"(17:52→17:55 / 18:50→18:52) | **两个都不成立**:17:46:43→17:56:15 该会话连续在跑工具(无 ≥55 s 的 addHistory 空档);18:51:39 到期时刻状态 = `tool_executing` | 撤销 | | ①「idle 钩子至今 0 次真实触发」 | 17:51–18:54 期间**仍然**成立(0 次,因闸门);但 **18:55:00 起不成立** ⇒ 现在 = **1 次真实触发** | 更新计数 | | ③「钩子热生效」(据此把 ② 判成"真结论") | 结论对,**但推理没错、结论没错而事实错**:热生效成立,只是被闸门掩盖 | 保留 | | ④–⑫ 其余各条 | 本棒未复核,⛔ 不作废但也不再当"支撑 ②"的依据 | 保留 | --- ## 7. 最小可行路径(成立 ⇒ 怎么用起来)与其代价 **落地形态(用现成触发源,0 新会话)** 1. 项目级 `.codebuddy/settings.json` 已有 `Notification` + matcher `^idle_prompt$`(现挂的是探针脚本)。 2. 把探针脚本升级为**仲裁投递器**(同一个脚本、同一处挂点): - 读 `CODEBUDDY_GATEWAY_PASSWORD`(实测可得)→ 发现本机网关端口 → `POST /api/v1/runs` 投一条消息给**目标会话**。 - **投给协调会话,⛔ 绝不投给自己**(投自己会自激:干活→空闲→唤醒自己→…)。 - 加**最小间隔节流**(如 ≥10 min)+**有活才投**(先读工作区状态,没待办就不投)。 3. 触发链:`会话空闲满 60 s → 宿主 spawn 钩子 → 钩子投递 → 目标会话被唤醒`;投递成立已实测(会话忙时排队、空闲即交付)。 **代价(老实讲)** | 项 | 量级 | |---|---| | 钩子侧成本 | **0 token、不占会话**(宿主子进程,timeout 8 s) | | 投递侧成本 | 每命中一次 = 唤醒目标会话跑一轮 ⇒ **真的烧 token**(必须靠节流+"有活才投"压住) | | 最坏情况 | 若投给自己/无节流 ⇒ **最坏 1440 轮/天** | | 已知失效模式 | **任何 pending/running 的后台任务会静默压制它**(今天实测 6h20m)⇒ 必须配自检(如让投递器记"上次成功时间",超时即告警;或借 `Stop` 钩子(**每个回合结束必触发、不受闸门压制**)做交叉校验) | | 装哪里 | 项目级已可用(对已跑会话也生效);要覆盖"所有工作区"须挂**全局**配置 ⇒ **属"改全局",须先问用户** | --- ## 8. 待用户拍板(一轮一问 · 唤醒落地三选项仍未定) **问题**:唤醒这条链,落地选哪一条? **为什么问你**:三条路的差别不在技术优劣,而在「**要不要长期占一个常驻东西**」和「**能不能接受它自己把会话叫起来烧 token**」——这属于资源承诺与取向,不是我能替你定的。 **① 只保留探针(现状,不动)** - 优点:零风险、零成本、零维护;机制结论已经拿到,随时可升级。 - 缺点:唤醒能力等于没有;链条仍然是靠"排自动化"这一条腿走路。 **② 外部定时器 → 网关 `runs`(唯一能做 5 分钟级)** - 优点:与钩子解耦、不依赖空闲闸门、可 5 分钟级;0 新会话。 - 缺点:要**长期养一个常驻进程**(本项目一向慎入:独立进程**读不到宿主口令**,需要人工给凭据并处理"端口每次重启都变");多一个要运维的活物。 **③ idle 钩子 → 网关 `runs` 闭环** - 优点:**用现成触发源**(今天已端到端验证触发成立)、钩子侧 0 token 0 会话、天然"只在不忙时"触发。 - 缺点:最坏 1440 轮/天(必须节流);**会被活的后台任务静默压制**(今天 6h20m),要先补齐自检;要覆盖全部工作区得动全局配置(须你同意)。 **倾向**:**③**(唯一"用现成触发源"的方案,且今天已把触发这一环坐实);落地时**必须**同时上"节流 + 有活才投 + 投给协调会话而非自己"三条护栏。若你要更稳,我建议 ③ 先只接**一条线**试跑一天,再决定要不要扩到全部工作区。 --- ## 9. 复现预测(留给下一棒的判定点) 本棒是**单次观测**,为把它从"1 次"变成"可复现",留一条**可falsify的预测**: > 本会话(`706d2476`)**本轮 done 后**,若不再有活动且**无活的后台任务**, > 应在**我最后一条 `addHistory` 完成时刻 + 60 s** 出现一行 > `session_id=706d2476-93fb-439a-9742-22e009915331` 的探针记录。 - 出现 ⇒ 机制**可复现**(且证明"新会话天然生效")。 - 不出现 ⇒ 先查本会话有没有活的后台任务(`[BashTool] background task` 生命周期),再谈机制。 ### 9.1 复现验证结果(2026-09-30 19:2x 追加 · **复现验证棒**) **判定:❌ 未出现。** `tmp/_idle_hook_probe.jsonl` 至今仍是 **2 行**(`PROBE-TEST-0001`、`6ecf6d98-…`),**没有** `706d2476-…` 行。 ⇒ §9 的预测**既未被证实、也未被证伪**:预测的前提("收官后不再活动、静置 >60 s")在 `706d2476` 上**根本没发生**。 **第一步先排除"被压制"(prompt 要求)—— 结论:不是被压制。** | 检查项 | 实测读数 | 结论 | |---|---|---| | 19:00 后 `[BashTool] background task` 四类事件(created / completed / failed / killed) | **0 条**(全文件最后一条 = 18:38:13 `NDucGY` failed,elapsed 22 827 768 ms) | ⛔ **无活的后台任务** ⇒ 不是第二道闸门压的 | | 全天含探针的 `[HookExecutor] spawn` | **1 条**:`18:55:00.210`(§2 证据①不变) | 19:00 后宿主**从未** spawn 过 idle 钩子 | | 预测时刻(= 末条 `[addHistory] COMPLETED` **19:06:25.337** + 60 s = **19:07:25**)该会话状态 | 该 addHistory 后 **0.4 s** 即 `MODEL_REQUEST_STARTED`(19:06:25.755)→ `MODEL_STREAM_STARTED`(19:06:27.976)→ 持续流式(19:06:48.075 累计 1 556 287 B),全程 `busy=true` | 第一道闸门 `!stateMachine.isIdle` **不通过** ⇒ 只重装表(静默) | | 该会话最终去向 | `[CliPrewarmPool] activated prewarm entry **exited unexpectedly** (id=wb-pool-1790765516875-2537cd, **pid=47188**, sessionId=706d2476-…, **code=1**, signal=none)` @ **19:09:16.570** | 🔴 **承载它的 CLI worker 猝死** ⇒ 宿主计时器随之消失 | **真因(一句话)**:`706d2476` 这一棒**一路忙到进程退出**(一直在流式生成、`busy=true`),**从未出现 60 s 空闲窗**;19:09:16 承载它的 worker 又以 `code=1` 退出 ⇒ 会话此后**零活动**(其会话日志冻结在 19:09:17、工作区日志冻结在 19:06:48、主线日志最后一行就是这次退出)⇒ **没有活着的宿主计时器**,自然不会有 idle 钩子。 ``` 时点 │ 19:06:25.337 19:06:25.8~19:06:48 19:07:25(预测点) 19:09:16.570 19:2x(本棒) ─────┼──────────────────────────────────────────────────────────────────────────────────────────────── 事件 │ addHistory COMPLETED ─► 请求/流式中(busy) ─► ✗ 本应 spawn 探针 ─► worker exited code=1 ─► 仍无探针行 │ ▲计时器装点 ▲闸门①不过(忙) ▲▓▓ 空的这格=本棒的靶子 ▲计时器随进程消失 ``` **顺带登记(⛔ 不属本棒判据,仅记录)**:19:09–19:11 出现**一波 3 个会话**的 `activated prewarm entry exited unexpectedly (code=1)` —— `706d2476` @19:09:16.570、`12732a10` @19:10:27.935、`ac8de40d` @19:10:59.886,三者的会话日志**都在各自退出时刻冻结**。疑似宿主侧 worker 回收/重启波;它**会连带灭掉 idle 计时器**,后续真要用 idle 钩子做唤醒,需先弄清这一波。 **⇒ 对链的结论**:机制**仍然成立**(§0–§3 的 18:55 端到端证据链未被动摇),但**"可复现"这一环今天没能补上** —— ⛔ 不是"机制不可复现",是**这次的样本自己死在半路**。要补这一棒,须挑一个**能正常收官并静置 >60 s** 的会话,⛔ 不要再用长回合/带后台任务的会话当靶子。 --- ## 10. 出处(按需读) - 宿主 spawn 记录:`E:/ProgramData/.workbuddy/logs/2026-09-30/ai1net-dsh-server__e476dc05d23a01f725b368d606f4ff0f.log:146869` - 后台任务生命周期(真因):同文件 `[BashTool] background task created|failed|finalized`(`NDucGY`:12:17:45 建、18:38:13 收、elapsed 22 827 768 ms) - 状态机:同文件 `[SessionRunStateMachine] transition | sessionId=6ecf6d98-…` - 计时器装点:同文件 `[addHistory] COMPLETED`(末条 18:54:00.193) - 探针:`tmp/_idle_hook_probe.jsonl` | 脚本:`.workbuddy/tools/idle-hook-probe.py` | 挂点:`.codebuddy/settings.json` - 取证工具:`tmp/wb-phone/asar-peek.mjs`(`find` / `slice`)+ `reflow.mjs` - 上游:`交付物/唤醒-idle钩子破解-20260930.md`、`交付物/查唤醒为什么断-结论-20260930.md`、`接续包_唤醒机制探索_20260930.md`