Files
dsh_ai1net_server/交付物/唤醒-idle钩子坐实-20260930.md
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

248 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 唤醒 · 「会话空闲 ⇒ 宿主 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`