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

245 lines
15 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.
# 「伪造 hook 上报主会话」= 不需要伪造:官方本来就有 **idle 钩子**(破解记录)
> 状态:**源码级定案 + 探针已落地 + 触发一环待实测坐实**
> 日期:2026-09-30 17:5x | 线:机制线 | 域:`ai1net-dsh-server/`
> 上游:本文接续 `交付物/唤醒机制-实现方案对比与选型-20260930.md`(其 §8 那三条通道本棒全部实测判负)
---
## 0. 一句话结论(先给答案)
**不用伪造。** WorkBuddy 的文件级钩子体系里**本来就有一个「空闲」事件**:
> 会话空闲满 **60 秒** ⇒ **宿主自己去 spawn 一个 `Notification` 钩子**
> (`notification_type = "idle_prompt"`,`message = "CodeBuddy is waiting for your input"`)
而钩子是**宿主起的子进程** ⇒ 它**自带网关口令**、**知道自己是哪个会话**、**不占会话、不花 token**。
⇒ 「钩子把消息投回主会话」这件事,**触发源是现成的,不需要伪造**。
🔴 **另一条同样重要的实测(17:55 反转)**:`POST /api/v1/runs` **真的能叫醒会话** ——
会话忙时它排队,**一旦空闲立刻把消息作为用户消息交付**,会话就此跑起一轮。
⇒ **不需要人切窗口。** 「外部程序叫不醒会话」这个结论**已被推翻**(详见 §1.5)。
---
## 1. 先说本棒实测判负的三条路(避免后人重做)
> 🔴 **本节结论在 17:55 被第四条路推翻了一半** —— 见下方 **§1.5**:`POST /api/v1/runs` **投递成功**。
> 本节这三条仍然判负,但"外部程序一律叫不醒会话"这个**大结论是错的**。
| 通道 | 实测结果 | 卡在哪(取证) |
|---|---|---|
| `POST /sessions/{id}/reply`(gateway 直连) | ⛔ 判负 | **只认 live**;非 live ⇒ `parkInQueue` + `hasWaiter=false`,**13:5x 投递后 4.5 小时未成为用户消息** |
| `POST /jobs/{id}/reply`(文件信箱) | ⛔ 判负 | 只在**进程活着**时写 `inbox/*.json`;**resume 起的 worker 从不 drain** —— 400 ms 轮询器 + 目录监听,**9 分钟整窗零动作**(inbox 文件原封不动、无 `.drain`、job 日志 0 字节、`idleSince` 冻结) |
| `stop → reply(落 pending) → respawn`(**设计上"正确的"那条**) | ⛔ 判负 | `reply` 变成 `{"delivered":false,"saved":true}` + 真的写出 `pending-reply.json` ✅;`respawn` 真的**消费掉**了它(文件消失)并把文本 `unshift` 进 `["--resume", sid]` 的 argv ✅;**但 worker 起不来一轮** —— `detail:"resuming…"` 冻结、`updatedAt` 不动、`firstTerminalAt:null`、job 日志 0 字节、SDK 会话 0 条 |
**对照实验(否掉"靶子特殊"的辩解)**:换 **未删除** 会话 A(cwd 同本会话)+ 未删除会话 B(cwd 在别的 workspace),**两例都同样卡死在 `resuming…`**。
⇒ 不是"靶子已删除",也不是"cwd 争用",是**这条路本身不产出运行**。
**结论**:这三条"外部程序→任意会话"的路,**在本机零成功实例**。
### 1.5 🔴 反转(17:55 实测):第四条路 `POST /api/v1/runs` **投递成功**
原判词「202 受理 ≠ 已执行,busy 时挂住不动 ⇒ 仍需 live」**是错的**。
**事实**:17:49:11 投出的自检文本(`runId c2b8b5ad`,投出时 `GET /runs/{id}` 一直 `active:true`)
⇒ **17:55 该文本作为「用户消息」真的出现在目标会话里**(正文与投出的一字不差:`这是一条连通性自检消息(本机 runs 接口验证)…RUNS-SELFCHECK-OK…`)。
**正确语义**(据此修正):
| | 旧(错) | 新(对) |
|---|---|---|
| `POST /api/v1/runs` | 挂住不动、不可用 | **「会话忙时排队,一旦空闲立刻交付」** —— 这正是"叫醒"要的行为 |
| `active:true` 的含义 | 挂死了 | **在等交付时机**(会话空闲即转成一次真实运行) |
| 是否算"无人值守唤醒" | 否 | ✅ **算**(外部程序 → 网关 → 目标会话;不需要人切窗口) |
🔴 **与前三条的区别**:`runs` 走的是 **`GatewayAcpBridge`**(它持有 waiter/队列并且**会被真正消费**),而 `sessions/{id}/reply` 在非 live 时只是 `parkInQueue`(**无消费者**)。
⇒ **同样是"投递",差别在"有没有消费者"。判据永远是「端到端跑出了一轮」,不是响应码。**
⚠️ 仍需的能力:`runs` 只能投给**该网关所服务的那个会话**,且需要网关口令。
⇒ 与 §2 的 idle 钩子**正好互补**:钩子在会话空闲时被宿主触发、且自带口令 ⇒ 它可以去 `runs` 自己 ⇒ **闭环**。
---
## 2. 源码级发现:idle 钩子(本棒关键突破)
出处:`app.asar` → `/cli/dist/codebuddy-headless.js`(另有 `codebuddy-lite-wb.mjs` 同构实现)
```js
resetIdleTimer(L){
let ei = this.idleTimers.get(L.id);
ei && clearTimeout(ei);
let ea = setTimeout(async () => {
this.idleTimers.delete(L.id);
try {
if (!this.stateMachine.isIdle(L) || this.hasLiveBackgroundTask())
return void this.resetIdleTimer(L);
await this.sessionHookManager.executeNotificationHooks(
L, "CodeBuddy is waiting for your input", IDLE_PROMPT);
} catch(L){ this.logger.warn?.("Notification (idle) hook failed:", L) }
}, 6e4); // ★ 6e4 = 60_000 ms
this.idleTimers.set(L.id, ea);
}
```
配套事实(同源同文件):
- 事件枚举:`NOTIFICATION = "Notification"`;`notification_type ∈ {permission_prompt, idle_prompt, auth_success, elicitation_dialog, agent_needs_input, agent_completed}`
- 匹配规则:`getMatchValue(NOTIFICATION) = ei.notification_type` ⇒ **matcher 用正则匹配 `idle_prompt`**
- 发射器:
```js
async executeNotificationHooks(L, ei, ea){ // ea = notification_type
...
if(!await this.hookManager.hasHooks(NOTIFICATION)) return;
let es = { session_id, session, transcript_path, cwd,
hook_event_name:"Notification", message: ei, notification_type: ea, ... };
await this.hookManager.executeHooks(NOTIFICATION, es);
}
```
- **自续条件**:`!isIdle || hasLiveBackgroundTask()` ⇒ **忙 / 有后台任务在跑,就不发**(这是天然闸门)
- 触发点还有 `PERMISSION_PROMPT`(要授权时)与 `AUTH_SUCCESS`(登录成功时)
- ⚠️ 同源另有一处 **PTY 服务**的空闲定时器(`getSessionIdleTimeoutMs()` 读 `CODEBUDDY_PTY_IDLE_TIMEOUT_MS`),**与钩子无关**,别混
---
## 3. 为什么这一条是「无人值守」(三条性质全中)
| 要求 | 满足方式 |
|---|---|
| 不需要人 | 触发源 = **宿主自己的 60 s 定时器**,与人在不在无关 |
| 不需要切会话 | 事件发生在**会话自己身上**,钩子唤醒的就是**它自己** |
| 零 token / 不占会话 | 钩子运行时**宿主不跑模型**;钩子是子进程,**不产生新会话** |
**「上报给主会话」这一半的硬前提已实测成立**(探针日志):
```
"gateway_password_present": true ← 钩子子进程继承了 CODEBUDDY_GATEWAY_PASSWORD
"env_names": [..., "CODEBUDDY_SESSION_ID", "CLAUDE_SESSION_ID", ...] ← 知道自己属于哪个会话
```
⇒ 钩子脚本里只要:**枚举本机回环口 → 三判据指纹认网关 → 按 `live.sessionId == 自己` 匹配 → `POST /sessions/{id}/reply`**(成品已有:`.workbuddy/collab/wake-session.py`)。
**关键时序优势**:钩子触发的那一刻,会话**正处在 idle**(源码就是这么判的)⇒ `hasWaiter=true`、`queueLen=0` ⇒ **投递应即时生效**,不会重蹈 §1 的 `parkInQueue`。
---
## 4. 本棒已落地的东西(安全、可逆、零注入)
| 项 | 内容 |
|---|---|
| 探针脚本 | `.workbuddy/tools/idle-hook-probe.py` —— **只追加一行 JSON 到 `tmp/_idle_hook_probe.jsonl`**;不联网 / 不发消息 / 不起子进程 / 不写 stdout / 永远 exit 0 / 读 stdin 带 3 s 兜底 / 日志 >8 MiB 停写留痕 |
| 挂载位置 | **项目级** `.codebuddy/settings.json`(本轮新建)—— ⛔ **刻意不装全局**,遵守本项目既有钩子 docstring 的规范;只作用于本工作区 |
| 全局配置 | 已备份 `E:/ProgramData/.workbuddy/settings.json.bak-idleprobe-20260930-175112`(**未改动全局**) |
| 自测 | 真喂 stdin JSON ⇒ `rc=0`,日志写入正确,`gateway_password_present=true` ✅ |
---
## 5. 还差的一环(唯一未坐实)
**「60 秒空闲 → 真的 spawn 钩子」目前是源码级确认,尚无现场观测**。已有一条**负向读数**:
- 17:52→17:55 本会话出现了**约 2.5 分钟的真空闲窗**(上一轮结束 → 下一轮被 `runs` 消息唤醒),
**远超 60 秒阈值,但 `tmp/_idle_hook_probe.jsonl` 没有新增行**(仍只有 17:51:51 那一条手工自测)。
⇒ 说明 **本会话根本不认识这个钩子** —— 与「项目级 settings 可能要**新会话**才加载」的判断一致
(本会话进程启动于 17:2x,而配置建在 17:51)。
- 参照:**全局** `settings.json` 的新钩子是**热生效**的(09-30 00:12 加 `PreToolUse ^Bash$` ⇒ 00:14 起就 spawn);
**项目级**这份显然没有这个待遇(或被本进程整份忽略)。
- ⇒ 坐实方法:**下一个在本工作区新起的会话**里,等它空闲 ≥60 s,再读 `tmp/_idle_hook_probe.jsonl`。
- 有行 ⇒ 项目级可用,**链路全通**,直接进 §6;
- 仍无行 ⇒ 项目级 hooks 对本场景不可用 ⇒ 退路=改用**全局** `Notification`(全局配置已有备份,可回滚;且它是热生效的)。
🔴 **顺带一条实测**:`POST /api/v1/runs` 既然能"空闲即交付",**它本身就可以当那条"定时叫醒"的腿** ——
由外部(或钩子)在空闲时投一发 ⇒ 会话跑一轮 ⇒ 见 §1.5。**idle 钩子不是唯一解**。
---
## 6. 若要把「自动唤醒」开起来:代价与闸门
🔴 **这是花钱/行为变更,⛔ 不擅自开**(属提问规范「边界外②花钱与资源 + ①业务优先级」)。
**代价**:链一旦闭环 = **每 60 秒一轮 AI** ⇒ 最坏 **1440 轮/天**(且 `hasLiveBackgroundTask()` 为真时不发 ⇒ 有活干时自动让路,实际会低于此值)。
**必需的三道闸门**(缺一即不可开):
1. **总闸**:一个开关文件,钩子读不到就**直接静默退出**;
2. **去抖/退避**:同一会话 N 秒内只唤醒一次;连续 K 次无产出则自动停;
3. **只干一件可判的事**:唤醒后**只做**「读 `tmp/supervise-inbox/` 判定有没有活」⇒ 无活**立刻结束本轮**,⛔ 不许闲逛。
**⛔ 明确不做**:不做「无限自唤醒」(会把会话变成 24×7 烧 token 的永动机),也不把它挂到全局。
---
## 7. 待拍板(一件事)
**问题**:要不要把 idle 钩子从「只记日志」升级成「空闲即唤醒主会话」?
**为什么要你定**:升上去之后,本工作区的会话会**每约 60 秒自动醒来跑一轮**(有人看着时也一样),最坏 **1440 轮/天**,直接花你的额度;且它改变的是"会话什么时候会动"这件事的默认行为。
**选项**(各有优劣):
**① 先只保留探针(只记日志)**
· 优点:零成本、零风险;先把「60 秒空闲真的会 spawn 钩子」这条坐实,再谈别的。
· 缺点:不产生任何实际唤醒能力,仍要靠自动化/人工启动。
**② 用「已证实可用」的那条腿:外部定时器 → `POST /api/v1/runs`**
· 优点:**投递这一半已经端到端实测成立**(§1.5:文本真的作为用户消息送达并跑起一轮);不依赖 idle 钩子、不依赖自动化、不需要切窗口。
· 缺点:仍需要一个**外部定时器**(本机起常驻进程受限);频率由它决定,定太快照样烧额度。
**③ 上「空闲即唤醒」:idle 钩子 → `runs` 自己 + 三道闸门**
· 优点:**触发和投递两半都闭环**,完全不靠外部定时器;只在真正空闲时动。
· 缺点:最坏 1440 轮/天烧额度;且当前**项目级配置本会话不生效**(§5 负向读数),要先解决"装哪里"。
**倾向**:**先 ①** 不改行为;同时**把 ② 备好**(投递脚本现成,缺的只是一个定时器)。
③ 要等你接受"会话永不真正休息"这个默认之后再做。
---
## 8. 泳道图:这次的三条路分别死在哪
```
外部进程 gateway 宿主/CLI 进程 目标会话
(脚本/钩子) :59xxx (WorkBuddy) (任意)
───────────────────────────────────────────────────────────────────────────────────
路① reply ──投──▶ 查 live ──▶ 非 live ⇒ 入队 ──▶ hasWaiter=false ✗ 永不消费
❌【红】此处断 (13:5x 投递,4.5h 无反应)
路② jobs ──投──▶ /jobs/{id}/reply ──▶ 写 jobs/<id>/inbox/*.json
reply ❌【红】400ms 轮询器 + 目录监听 = 从不 drain
✗ worker 不动
路③ respawn ──投──▶ reply 落 pending ✅ ──▶ respawn 消费 ✅ + argv 拼 --resume ✅
❌【红】worker 卡 resuming…
(A/B 对照:换未删除会话、换 cwd —— 同样卡)
───────────────────────────────────────────────────────────────────────────────────
✅ idle 钩子:目标会话自己空闲 60s ──▶ 宿主 spawn Notification 钩子
──▶ 钩子自带口令+会话id ──▶ reply 投回自己
──▶ 此刻正 idle ⇒ 即时进一轮 ▲ 全绿
```
## 9. 事故链:为什么之前"看着通了"却从没真通过
```
把"200 返回"当成"已唤醒"
↓ (缺少防线①:没回查 history / lastUserMessage / updatedAt)
delivered:true ⇒ 判为成功
↓ (缺少防线②:没查消费端 —— hasWaiter / queueLen / tempo)
消息躺在 inbox / 队列里没人读
↓ (缺少防线③:没有"端到端跑出一轮"的判据,只看单点响应码)
结论写进档案,后续都建立在"已通"之上 ⇒ 反复绕回同一个死胡同
```
**本该拦住的三道防线**:① 回查落地(消息有没有成为用户消息)② 查消费端有无 waiter ③ **只认"跑出了一轮"**(transcript/日志/state 三处任一推进)。
---
## 10. 出处与可复现命令
| 物 | 路径 |
|---|---|
| 探针脚本 | `.workbuddy/tools/idle-hook-probe.py`(`--selftest` 可手工跑) |
| 探针日志 | `tmp/_idle_hook_probe.jsonl` |
| 项目级钩子配置 | `.codebuddy/settings.json` |
| 全局配置备份 | `E:/ProgramData/.workbuddy/settings.json.bak-idleprobe-20260930-175112` |
| 唤醒通道成品 | `.workbuddy/collab/wake-session.py`(`--list` 现查网关) |
| 取源工具 | `tmp/wb-phone/asar-peek.mjs`(`list` / `find` / `extract`) |
| 本棒实测脚本 | `tmp/_wake_via_jobs.py`、`tmp/_wake_respawn.py`、`tmp/_wake_control.py`、`tmp/_jobs_*.py`、`tmp/_api_raw.txt` |