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

19 KiB
Raw Permalink Blame History

唤醒 · 「会话空闲 ⇒ 宿主 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

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