- 变更规模:新增 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/ 知识文件,按口径入库)
19 KiB
唤醒 · 「会话空闲 ⇒ 宿主 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 秒",而是两道隐形闸门同时满足:
- 会话状态机 =
idle(每次addHistory都重置 60 s 计时器 ⇒ 回合进行中永不计时) - 该会话没有 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 新会话)
- 项目级
.codebuddy/settings.json已有Notification+ matcher^idle_prompt$(现挂的是探针脚本)。 - 把探针脚本升级为仲裁投递器(同一个脚本、同一处挂点):
- 读
CODEBUDDY_GATEWAY_PASSWORD(实测可得)→ 发现本机网关端口 →POST /api/v1/runs投一条消息给目标会话。 - 投给协调会话,⛔ 绝不投给自己(投自己会自激:干活→空闲→唤醒自己→…)。
- 加最小间隔节流(如 ≥10 min)+有活才投(先读工作区状态,没待办就不投)。
- 读
- 触发链:
会话空闲满 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