Files
dsh_shenxian/dsh-server-docs/04-调整方案/99-会话自决失效根因与Stop钩子补强.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

106 lines
9.0 KiB
Markdown
Raw 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.
# 99-「会话自行判断继续」失效的根因与机制补强(Stop 钩子)
- 日期:2026-09-15 | 状态:🔄 **已落地待生效**(钩子已装;**待一次完全重启**后当场实测)
- 触发:用户报「之前设置的**会话根据决策方法自行判断继续处理任务**的功能执行的不太好,会话**经常忘记这个事**,又让确认很简单的问题」
> **TL;DR**
> 1. **根因 = 拦截点错位**(不是"AI 不听话"):既有「提问闸门」hook 的 matcher 是 `^AskUserQuestion$`,只能拦**工具调用**;实测本工作区宿主日志 `tool=AskUserQuestion` 调用数 = **09-12: 43 / 09-13: 3 / 09-14: 0 / 09-15: 0** ⇒ 真实上抛发生在**回复正文**("要我接着做吗 / 请确认 / 说一声即可"),**零机制覆盖**。
> 2. **补强 = `Stop` 事件钩子**(官方 27+ 事件之一,**输入带 `transcript_path`**):扫最后一条回复的**最后一行**,命中征询句式 ⇒ 返回 `{"continue": false, "reason": …}`,让 Agent **自我纠正**(该自己做的做掉 / 该问的写进「需要你拍板」一节)。
> 3. **形态定型(实测数据,非推断)**:单 python 脚本 + **`python -S -E`** = **185 ms/轮**(对照 286 ms);**成本与转录大小无关**(70.4 MB 转录仍 286 ms)。⛔ **否决** bash 预筛层 —— 本机 `bash -c exit 0` = **1072 ms/次**,比 python 还慢 3 倍。
---
## 一、背景与动机
- 用户报障背景(本机 5 分钟前的会话):`63615df4`「检查项目是否支持用户自定义 skills」(07:19 起,07:30:30 仍在写)。
- ⚠️ **诚实边界(拿不到的东西不编)**:**该会话的正文原文我拿不到** —— 会话正文只在云端;本机只有事件级日志(`tool_call` 类型、**无文本**)、`projects/` 无该 sid 的 jsonl、宿主日志只记 `tool=`。**但结论不受影响**:当天(09-15)**没有任何 `AskUserQuestion` 调用** ⇒ 那个问句只可能出现在**正文**里。
## 二、用户决策(本次三条,均已沉淀)
| 用户原话 | 沉淀 |
|---|---|
| 「先解决风险和问题,做到最优效果确认无误后再执行」 | 本档案 §五 的"风险与缓解" |
| 「决策中发现方案有风险和问题,需要分析并优化到当前情况和状态下的最优解,然后进行下一步处理」 | **U26**(素材库·用户决策) |
| 「项目推进要的是**解决问题**,不是**得过且过、将就妥协**」 | **U27**(同上;并给 **A6** 补边界:"最小代价 ≠ 降低目标") |
## 三、方案对比(含"不做")
| 方案 | 判定 | 理由 |
|---|---|---|
| A 只靠常驻层自检(`CODEBUDDY.md §1`「回话前自检」) | ⚠️ **不足** | **用户最初的报障就是反证**:规则已在,会话仍旧照问 ⇒ 只有"判据"没有"强制"(A24 的教训) |
| B 把 `PreToolUse` 扩展到正文 | ❌ 不可行 | `PreToolUse` 只在**工具调用前**触发,正文不是工具 |
| **C `Stop` 事件钩子(command)** | ✅ **采用** | **唯一**能覆盖"回复正文"的面;官方事件、输入带 transcript |
| D 定期巡检会话日志、事后纠正 | ❌ | 事后纠正 ≠ 当场拦住;且要读会话正文(本机不存) |
| E 不做、接受现状 | ❌ | 与 **U27** 冲突(将就妥协) |
## 四、实现
| 文件 | 内容 |
|---|---|
| **`scripts/stop-dialog-guard.py`(新,已入库/入镜像)** | 读 stdin 的 Stop 载荷 → 自作用域(transcript_path 含 `aliyun-dsh-server`)→ 急停双闸 → **防死循环**(`stop_hook_active`)→ 从转录**尾部**取最后一条 assistant 文本(分块扩窗 256 KB ×4,上限 4 MB)→ 只看**最后一行** + 引用豁免 → 命中则限频记账 + 写日志 + 返回 `{continue:false,reason}` |
| `settings.json` 的 `hooks.Stop`(新增 1 条) | `"<python>" -S -E "<脚本>"`,timeout 10 s —— **只加一个键**,`PreToolUse`×2 / `SessionStart`×1 **未动**(已断言) |
| 配套(本档案同批) | `dsh-decision-method` **v2.6.0** 新增 **U27**、给 **A6** 补边界、项目根 `CODEBUDDY.md §1` 加一行指针 |
## 五、验证记录(含**失败尝试**)
**已完成(库外合成样本,5 组 + 4 组)**:
| 项 | 结果 |
|---|---|
| 判据(收尾征询句 / 陈述句 / "需要你拍板"节 / 正文引用 / 换 sid) | **5/5** |
| 限频(同会话 600 s 内第二次)→ 放行 | ✓(且改为**命中才记账**,修掉"只查也记账导致误吞下一轮") |
| 急停 env / 急停闸刀文件 | ✓ / ✓ |
| 巨行 14 MB(旧版静默漏判) | 已修:分块扩窗;超窗放行**留痕** |
| 性能 | `python -S -E` 185 ms/轮;70.4 MB 转录 286 ms(**与大小无关**) |
**失败尝试(如实记)**:
- ❌ **headless CLI 预验证两次卡死**(`codebuddy-headless.js -p` 无输出,4–5 min 后被 kill)⇒ **放弃该验证路线**(有依据的放弃,不是回避)。
⇒ 改用**最直接的验证**:重启后在本会话**故意以征询句结尾**,观察是否被拦回、`reason` 是否注入 —— 30 秒可得结论。
**待做(唯一未闭环项)**:**一次完全重启** → 当场实测(见 §七)。
## 六、事故 / 踩坑(都是我自己埋的)
1. **巨行漏判**:一次性尾窗会切在 JSON 行中间 ⇒ `json.loads` 失败 ⇒ **静默放行**(实测复现)→ 改分块扩窗 + 留痕。
2. **限频误吞**:`_rate_limited` 在"只查"时也记账 ⇒ 同会话**下一轮的合法拦截被吞** → 改 `peek=True` 只查不写。
3. **误报一次**:正文里**引用**规则("脚本里写着「说一声即可」")被当成收尾句 → 加**引用/转述豁免**。
## 七、验收(重启后 30 秒)
1. **我**在任一回复里**故意**以「要我接着做这件事吗?」结尾 ⇒ 若钩子生效,我会被拦回并看到 `reason`(征询句判据)⇒ **L5 用户可见**。
2. 反向用例:以陈述句结尾 ⇒ **不应**被拦(验证不误伤)。
3 生效后我立刻回报,并把结论追加到本档案(§五)。
## 八、回滚 / 注意
- 🧯 **秒级急停(不用重启)**:新建 `<工作区>/.workbuddy/stop-guard.disabled`,或给会话进程设 `DSH_STOP_GUARD_OFF=1`。
- 🗑 **彻底卸载**:删 `settings.json` 的 `hooks.Stop` 键 → **一次完全重启**。
- 备份:`settings.json.bak-stopverify-075408`(首次验证前)、`settings.json.bak-stopverify2-075909`(第二次验证前)。
- ⚠️ **与 `/goal` 并存未验证**(两者都是 Stop 类机制)⇒ 同一会话**别同时用**。
- ⚠️ **每轮回话 +~0.2 s**(进程冷启动,压不掉);`Stop` **不支持 matcher** ⇒ 其他项目的会话也会付这一份(脚本自作用域,**行为不变**)。
---
## 修正(2026-09-15 · Stop 钩子该版本不生效 ⇒ 第二方案 `UserPromptSubmit`)
**实测结论(决定性,非推断)**:Stop 钩子装好并**完全重启**后(`last-launch.json` = 08:09:52、新 PID),
用**已验证会命中**的探针句结尾,且脚本已改为「**本工作区内每次调用必写一行**」⇒ **日志仍为空**
⇒ **WorkBuddy 5.5.6 桌面版不调用 `Stop` 钩子**。按「不留假安全感」原则**已卸载**(`hooks.Stop` 已删,备份 `settings.json.bak-stopuninstall-082503`)。
**第二方案:`UserPromptSubmit`**(官方文档确认其输入**同样带 `transcript_path`**,且支持
`hookSpecificOutput.additionalContext` 注入上下文 ⇒ 覆盖同一条面:**本轮开始前**让模型看到"上一轮收尾是征询句"):
| 项 | 设计 |
|---|---|
| 触发时机 | 用户提交提示词后、模型处理前 ⇒ **每轮必触发**(不像 `Stop` 只在模型停止时) |
| 两级模式 | **`probe`**(只写日志,零风险,用于判定"有没有被调用")→ **`inject`**(命中则注入上下文让本轮自我纠正) |
| 切换方式 | 写 `<工作区>/.workbuddy/stop-guard-mode`(内容含 `inject` 即切)⇒ **改文件即生效、无需再重启**(只有 settings 的 hooks 条目是启动快照) |
| 不误伤 | 只在**最后一行**是征询句时注入;陈述句 / 「需要你拍板」节 / 正文引用 均不触发(自测 **4/4**) |
| 急停 | `DSH_STOP_GUARD_OFF=1` 或 `<工作区>/.workbuddy/stop-guard.disabled` |
**当前状态**:已装为 **probe 模式**(只记日志、不注入、不阻断)。**在你下一次自然重启后**它会开始写日志;
届时读 `<工作区>/.workbuddy/stop-dialog-guard.log`:出现 `invoked(user-prompt)` 行 ⇒ 该面可用 ⇒ 我把模式切成 `inject`(改一个文件、即时生效);
仍无行 ⇒ 该版本连这条也不调用 ⇒ 只剩「常驻规则 + 每轮自检」(已落地)这一层,届时如实收口,不再留任何"看起来装好了"的东西。
**顺带修掉一个真 bug(A18 教科书案例)**:本脚本 `main()` 的 `except: pass` **把一个 `NameError` 藏了半小时**
(我调用了本文件不存在的 `out()`)⇒ 已改为**异常必留痕**(写 `EXCEPTION|…` 行)。