Files
dsh_shenxian/dsh-server-docs/04-调整方案/73-让锁真正拦得住人-措辞修正与PreToolUse强制钩子.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

276 lines
20 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.
# 73 · 让「锁」真正拦得住人:措辞修正 + PreToolUse 强制钩子
- 日期:2026-09-12
- 触发:用户「会话并行修改不是应该有锁的机制吗,**知道这个机制吗**」→ 复盘发现机制**存在**,但**当天被跳过两次**(含我自己的那一次)
- 结论一句话:机制不缺,缺的是**强制**。根因有两条 —— ① **没有任何强制入口**(`settings.json` 的 `hooks` 段实测为 `null`)② **guard 的输出语义有歧义**:「无全局锁」被读成「环境干净,可以开工」,而正确读法是「**你快去抢锁**」。本次两条都治:措辞修正(库内)+ **PreToolUse 强制钩子**(配置层,无锁直接拒写)。
- 状态:✅ **已实现并单点验证**;⚠️ **钩子待用户在 `/hooks` 面板审核启用**(外部改 `settings.json` 需审核才生效)
- 关联:档案 69(三把锁的建立)/`交接单/README.md` §一 · §三 0·13 · §六 /`scripts/handoff-guard.sh` · `op-lock.sh` /`scripts/lock-guard-hook.py`(本次新增)/决策方法 `dsh-decision-method` §4.4
---
## 一、问题:机制有,人没遵守
| 谁 | 情况 |
|---|---|
| **本会话** | 做 T06(实例回收自动恢复)时,只跑了 `handoff-guard.sh` 的**信息模式** —— 看到「(无单级锁)(无全局锁)」→ **读成"环境干净,可以动手",直接开工**。正确动作是:**没锁 ⇒ 下一步就是抢锁**。 |
| **另一个会话** | 改 `README.md` / `DEPLOY-本部署.md` / `skills/…/SKILL.md` 时也没占锁(当时三把锁全空)。 |
**定性(按 `dsh-decision-method` X3 的判据)**:冲突只认两个硬信号 —— **别人的占用锁** + **待推送清单里的未声明文件**。按此复核,**双方改的文件不重叠,没有产生实际损害** → 本次属**流程失效,不是事故**(避免过度反应)。但它确实在同一文件 `INDEX.md` 上留了残留(对方 1 行改动被我的 commit 收进去)。
## 二、根因(两条)
1. **没有强制入口** —— 三个位置的 `settings.json`(用户级 / 项目级 / 库内)**`hooks` 段全为 `null`**。锁完全靠"自觉"。
2. **`handoff-guard.sh` 的输出语义有歧义** —— 「✓ 无全局锁」在视觉上像"检查通过",而它的**真实含义是"尚未持锁"**。这是本会话踩坑的直接原因,**是我自己写的文案没说清**。
## 三、方案对比(按 `dsh-decision-method` §4.4 裁决顺序)
| 方案 | 内容 | 判定 | 理由 |
|---|---|---|---|
| **a. 措辞修正** | guard 无锁时明确输出「⚠ 不是可以开工,是**你快去抢**」+ 交接单补语义 | ✅ **采用** | 治第 2 条根因;库内改动、零风险 |
| **b. 只做 a** | —— | ❌ | 约定**拦不住不看的人**(今天已证) |
| **c. PreToolUse 强制钩子** | 写本库/代码库时**无锁直接 deny** + SessionStart 打印锁状态 | ✅ **采用** | 治第 1 条根因;`§4.4 第 3 条「能配置解决就不改码」`命中(hooks 是配置层,免 build 免重启) |
| **d. 拦 Bash** | 连 `bash` 写文件也拦 | ❌ **不做** | **会把自己锁死**(抢锁命令本身要能跑);Bash 写文件是少数派且破坏面可见(`git status`) |
| **e. 靠自觉** | —— | ❌ | 今天已证明不可靠 |
**十问自检(关键四条)**:影响谁 → **仅本库/本代码库**(路径前缀匹配,**对其他项目零影响**)|断多久 → **不中断任何服务**|回滚 → 删 `settings.json` 的 `hooks` 段或面板关闭|验收 → 四态命令可复现(见 §四)。
## 四、实现
### 4.1 措辞修正(库内)
| 文件 | 改动 |
|---|---|
| `scripts/handoff-guard.sh` | ① 【1】单级锁「✓ 无人占用」→ 追加「⚠ 这不是「可以开工」,是「**你快去抢**」」+ 抢锁命令<br>② 【1c】「✓ 无全局锁」→ 改为「⚠️ 这**不是「可以开工」,是「你快去抢」**」+ 命令 + 一句判据(「环境干净」≠「没人动过」)<br>③ 信息模式结论行追加同样的提示,并注明 2026-09-12 的实证 |
| `交接单/README.md` §一 | 两级锁小节补一条:「**「无锁」的正确读法 =「你快去抢」**;看到无锁 ⇒ 下一个动作就是 `--claim-exec`;**抢不到 = 有人在跑 = 停手**」 |
### 4.2 强制钩子(`scripts/lock-guard-hook.py`,新增)
| 事件 | 行为 |
|---|---|
| **PreToolUse**(matcher `Write\|Edit`) | 目标路径落在**受保护根**内 且 **`.exec-lock` 不存在** → 输出 `permissionDecision: "deny"`,理由含**可直接粘贴的抢锁命令** |
| **SessionStart** | 打印锁状态:已占用 → 显示占用者;空闲 → 提醒「空闲 ≠ 可以开工,动手前先抢锁」 |
**作用域(刻意收窄)**:只判两类根 —— `DSH_DOCS_ROOT`(默认 `E:\ProgramData\AI技能\aliyun-dsh-server\dsh-server-docs`)与 `DSH_CODE_REPO`(默认 `D:\github\dsh_shenxian`),可用 env 覆盖。**其余路径一律放行** → 对其他项目零影响。
**四态单点验证(用临时库模拟,真锁未被触碰)**:
| # | 场景 | 期望 | 实测 |
|---|---|---|---|
| ① | 受保护路径 + **无锁** | deny | ✅ 返回 deny + 完整理由 + 抢锁命令 |
| ② | 受保护路径 + **有锁** | 放行 | ✅ 空输出 |
| ③ | **非**受保护路径 | 放行 | ✅ 空输出 |
| ④ | SessionStart(有锁态) | 提示占用者 | ✅ 「🔐 全局执行锁【已被占用】:tester」 |
**异常安全**:payload 解析失败 / 脚本内部异常 → **一律放行**(`except: pass` + 退出码恒 0),绝不因为钩子自身问题阻断工作。
### 4.3 启用方式(**需用户操作**)
写入 `~/.workbuddy/settings.json` 的 `hooks` 段:
```json
{
"hooks": {
"PreToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "python \"E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs/scripts/lock-guard-hook.py\"", "timeout": 10 } ] } ],
"SessionStart":[ { "matcher": "startup", "hooks": [ { "type": "command", "command": "python \"E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs/scripts/lock-guard-hook.py\"", "timeout": 10 } ] } ]
}
}
```
⚠️ **外部修改 `settings.json` 需在 `/hooks` 面板审核后才生效**(本次由我写入配置,**启用动作留给用户**)。
## 五、为什么刻意不拦 Bash(重要)
1. **抢锁命令本身必须能跑** —— 否则「拿不到锁就不能改文件、不能改文件就抢不了锁」= 死锁;
2. Bash 写文件是少数派,且破坏面**可见**(`git status` / 对账能查出来);
3. 宁可留一个**显式的安全阀**(Bash 可绕),也不要一个可能导致死锁的强制层。
## 六、回滚
- **措辞**:`git revert <本次 commit>`。
- **钩子**:删 `settings.json` 的 `hooks` 段(或在 `/hooks` 面板关闭);`lock-guard-hook.py` 为独立文件,可留可删。
- 钩子**不加任何持久状态**,不写锁、不写日志。
## 七、遗留 / 边界
1. **归属判定未做**:钩子只判「**有没有锁**」,不判「锁是不是你的」(hook 拿不到会话名与锁 OWNER 的对应关系)。这已足够 —— 今天的失败模式正是"**两边都没抢锁**",钩子会**两边都拦**,而抢锁是原子的 → 串行化达成。
2. **Bash 是显式缺口**(见 §五),不修。
3. **钩子只在 WorkBuddy 层生效**:若换个工具(或用户手工编辑文件)绕过,仍以约定与台账为准。
4. 本次**未改动 `04-调整方案/` 既有档案**(遵守库内新约定「**档案只增不改**」);与此前档案冲突处一律以**本档 + 入口文件**为准。
---
## 八、补:让规则真正到达每个会话(用户追问后)
**用户追问**:「关键是**别的会话怎么知道遵循这套规则**,比如另外 5 个会话也在执行 dsh 服务相关任务」
**核实结果 —— 这是真缺口**:
| 载体 | 何时被会话看到 | 现状 |
|---|---|---|
| `~/.codebuddy/CODEBUDDY.md`(用户级) | 启动时注入 | ❌ **不存在**(`~/.codebuddy/rules/` 也没有) |
| **项目根 `CODEBUDDY.md`** | **启动时注入 → 覆盖本工作区所有会话** | ✅ 存在,且**已有 §6「并发纪律」**—— 但内容有漏(见下) |
| `.codebuddy/rules/*.md` | 条件注入(碰对应文件时) | ✅ 3 个(archive-doc / frontend-ui / server-ops)—— 均未提锁 |
| `dsh-server-docs/CODEBUDDY.md` | 条件注入(碰库内文件时) | ✅ 存在 —— 管文档库约定,未提锁 |
| Skills(`dsh-change-workflow` / `dsh-feature-first`) | **被触发才加载(不保证)** | 有「三把锁」章节,但**不保证到达** |
| SessionStart / PreToolUse hook | 会话启动 / 动手时 | 已配,**待审核** |
**缺口精确到两处 —— 都在唯一可靠的载体里**:
1. **§2 表格**:「**改任何文件之前** → 跑 `handoff-guard.sh`」—— 只写「**跑**」(检查),**没写「抢」**。
**这正是我当天翻车的同一处**("跑了检查、看到无锁、直接动手")。
2. **§6 并发纪律**:只有「`--claim <单号>`」(**细锁**,管"同一个单别被两人做"),
**漏了「全局执行锁 `--claim-exec`」** —— 而它才是"**多个会话同时干活**"场景的**唯一防线**
(细锁**管不住跨单撞车**:5 个会话各做各的单,细锁互不冲突,但都会改 `README`/`INDEX`/`03-路线图`)。
**已补齐**(改 `CODEBUDDY.md`,改前已手工备份):
| 位置 | 改为 |
|---|---|
| §2 该行 | 「**改任何文件之前(第一步,不是"检查"是"抢")**」+ 完整 `--claim-exec` 命令 + 「**抢不到 = 停手**」 |
| §6 | 新增「**三把锁,顺序固定**」表(粗/细/生产,含各自命令与职责)+ 「**「无锁」= 你快去抢**」读法 + 指向本档与 `lock-guard-hook.py` |
### ⚠️ 两条必须知道的限制
1. **`CODEBUDDY.md` 是启动时加载** → 本次改动**对已经在跑的会话无效**(要重启才重载)。
⇒ **对"已经在跑的 5 个会话",唯一即时生效的手段是**:
- **hook**(`PreToolUse` 会**硬拦**,无需会话配合);
- 或**用户直接在那些会话里说一句**「动 dsh 前先抢锁」(最直接,一轮见效)。
2. **项目根 `CODEBUDDY.md` 不在任何 git 仓库内**(项目根不是仓库,也不在文档库仓库里)→
**无版本保护**,只能靠手工备份(本次已留 `.bak-locksect-<ts>`)。
**建议把规则文件纳入版本管理**(否则它的改动既无锁保护、也无历史)—— 属"是否需要新造机制",
按 §4.4 留作独立决策,**本次未做**。
---
## 九、启用步骤(**用户在 WorkBuddy 里操作**)
### `/hooks` 面板在哪
**在 WorkBuddy 的对话输入框里输入 `/hooks`**(斜杠命令)→ 打开 hooks 配置面板。
官方文档原文:*"/hooks CLI panel for reviewing and approving any configuration changes before they take effect, ensuring safety."*
(外部改 `settings.json` **必须经此面板审核/批准**才会生效 —— 这是官方安全机制,不是我这边的限制。)
### 步骤
1. 输入框敲 `/hooks` 回车 → 面板打开
2. 找到 **`PreToolUse`**(matcher `Write|Edit`)与 **`SessionStart`**(matcher `startup`)
3. **审核** command 指向 `dsh-server-docs/scripts/lock-guard-hook.py` → **批准/启用**
4. 按 `Esc` 返回
### 启用前的加固(2026-09-12 实测)
官方文档明确:**Windows 上 hooks 强制走 Git Bash**(`cmd.exe` / PowerShell 不支持)→
命令里的裸 `python` **若不在 hook 的 PATH 里就会直接失败**。故把 command 改为**绝对路径**:
```
"D:/miniconda3/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/dsh-server-docs/scripts/lock-guard-hook.py"
```
**实测记录**:`env -i "D:/miniconda3/python.exe" "<中文路径>/lock-guard-hook.py"`(**干净环境、不继承 PATH**)
→ 退出码 0、行为正确(持锁态放行 / 无锁态 deny)。即:**绝对路径 + 中文路径均可用**。
### 启用后自检(第三方可复现)
| # | 操作 | 期望 |
|---|---|---|
| 1 | 无锁状态下,让任一会话改 `dsh-server-docs/INDEX.md` | **被拦**,并返回可直接粘贴的抢锁命令 |
| 2 | 先 `--claim-exec` 抢锁,再改同一文件 | 通过 |
| 3 | 改 `D:/其他项目/xx.md`(不在受保护根内) | 通过(**其他项目零影响**) |
| 4 | 新开会话 | 启动时打印锁状态提示 |
### 若不启用
库内约定仍然存在(项目根 `CODEBUDDY.md` §2/§6 + `dsh-server-docs/CODEBUDDY.md` + `交接单/README.md §一`),
但**只对"读过它"的会话有效** —— 这正是"另外 5 个会话不知道"的成因(见 §八)。
⇒ **对已在运行的会话,hook 是唯一不需要它们配合的到达方式**。
---
## 十、⚠️ 修正:桌面版没有 `/hooks` 面板(2026-09-12 17:45,用户实测反馈)
**用户反馈**:按 §九 输入 `/hooks` —— **什么也没有**。
**修正**:`/hooks` 是 **CodeBuddy CLI 的命令**,**WorkBuddy 桌面版没有这个面板**。
**§九 的操作步骤对桌面版不适用。** 成因如实记:那是照着 CLI 文档写的,**没在桌面版核实** ——
教训:**跨端能力先在目标端实测,再写进文档**(同 `§4.3 验收口径分级`:L1 推断不能当 L5)。
### 桌面版的正确加载方式(第三方探针实测记录)
> ① ✅ **2026-09-13 实测确认本行原文正确**:hook 命令**在会话启动时快照**,改完 `settings.json` **对已在跑的会话无效**(须「完全重启」或新开会话)。⚠️ 当天一度被误写成「每次调用现读」、并据此宣布本行作废,**已纠正**(成因见文末「修正」节)。**路径失配时 fail-closed** —— hook 打不开脚本 → 报错 → 该机所有会话的 Write/Edit 全被拒。别把「被拒」当成"锁被别人占了",先去核对 hook 里的绝对路径。
> ② **关窗 ≠ 退出** —— WorkBuddy 有常驻能力,点关闭按钮只是关窗口、**进程还活着**,必须**彻底退出**。
**本机实测印证**:当前有 **5 个 `WorkBuddy.exe` 进程**在跑(见 17:42 的 `tasklist` 输出)→ "关窗不退出"属实。
### 正确步骤(Windows)
1. **彻底退出 WorkBuddy**:托盘图标右键 → 退出;或任务管理器结束**所有** `WorkBuddy.exe`(含后台进程)
2. **重新启动** WorkBuddy
3. **验证是否生效** —— 本次为此新增了**自证手段**:hook 会写低频日志到
`E:\ProgramData\AI技能\aliyun-dsh-server\.workbuddy\lock-hook.log`(可用 `DSH_LOCK_HOOK_LOG` 覆盖)
```
2026-09-12 17:42:17 SessionStart 空闲 source=startup
2026-09-12 17:42:18 PreToolUse-deny Edit D:/.../a.md
```
| 观察 | 含义 |
|---|---|
| 重启后出现 **`SessionStart` 行** | ✅ 配置已加载、hook 已生效 |
| 之后无锁改本库被拦 → 多一行 **`PreToolUse-deny`** | ✅ 强制层工作正常 |
| **两行都没有** | ❌ 未生效(回来反馈,改走下一步) |
日志**只记 SessionStart 与 deny 两类**(低频),不记录每次写操作,避免刷屏。
### 若桌面版最终不支持 hooks
则本机制退化为「**约定 + guard**」,届时需要另找"到达方式"。按 `dsh-decision-method §4.4`,
届时候选顺序为:① 让 `--claim-exec` 成为**每次动手前的固定动作**(写进 `CODEBUDDY.md`,已做)
② 把检查做进**更早的钩子点**(若桌面版有其它可挂的事件)③ 由**用户在每个会话里说一句**(最直接但需人工)。
---
## 十一、⚠️ 修正:禁止「人工删锁 / 接管」(R9,2026-09-12 用户明令)
**用户原话**:「**严格禁止这类操作必须!!!!!!记录到红线中**」,并引用本库当时仍在流传的一句操作指引:
> 接管 —— 按交接单的流程**人工删锁**(`rm -rf 交接单/.exec-lock`)由我接手。但这等于判定那个会话已死/已完成,风险是它其实还在跑 → 撞车,所以我需要你明确点头。
**定性**:这句话**本身就暴露了机制缺陷** —— 它把"接管"的判据交给**人的感觉**("疑似已死"),而平台**没有心跳机制**,AI **没有任何客观判据**能确认对方是否还在跑。删锁 = 在**无法验证**的前提下单方面撤销互斥 → 一旦对方仍在跑,就**退回「两个会话同时改同一批文件」**,而后者正是三把锁存在的唯一理由。
**根因不是人不懂,是文档在教**:当时全库有 **4 处**写着"人工删锁",连 `handoff-guard.sh` 自己的抢锁失败提示里都在教。
**处置(2026-09-12 18:50 本库,持全局锁下完成)**:
| # | 位置 | 改动 |
|---|---|---|
| 1 | 项目根 `CODEBUDDY.md` §3 | 新增红线 **R9**(另一并行会话先行落地,已核对内容一致) |
| 2 | 项目根 `CODEBUDDY.md` §6 | 增「⛔ 抢不到锁就是终点,不是待办」一条 |
| 3 | `dsh-server-docs/CODEBUDDY.md` | 锁小节增 R9 指针 + 「guard 输出不构成授权」 |
| 4 | `交接单/README.md` §三 13 条 | 「**接管**:…人工删锁…」→ **改为禁止**,原文标注「已作废」 |
| 5 | `交接单/README.md` §三 11 条 | 「接管必须无损」加限定:**接管动作不得由 AI 自行发起** |
| 6 | `scripts/handoff-guard.sh`(2 处:抢锁失败提示 + 信息模式提示) | 删掉"人工删锁 / 接管",改为 **R9 禁止 + 停手 + 报告用户** |
| 7 | `skills/dsh-change-workflow/SKILL.md`(工作副本 + 归档副本,两处 md5 需一致) | 三把锁章节增 R9 条目 |
**R9 唯一合规路径(判据)**:
1. 抢不到锁 ⇒ **停手**(不重试、不"抢一下看看"、不绕道);
2. **报告用户** —— 说清「谁占着 / 占多久 / 我卡在哪」,而不是自己找理由;
3. **锁的处置权只属于用户本人** —— 要撤也只能用户自己动手,AI 不得代判断;
4. 仅在「**用户已点头 + 用户自己撤锁之后**」,才读 `OWNER` + 台账进度**续做**(T03 的 A→B 那套只适用于这种情形)。
**验证**:`bash -n scripts/handoff-guard.sh` → 语法 OK;全库 `grep -rn 人工删锁` → **只剩"禁止性条款 / 已作废说明",无任何还在教人删锁的位置**。
**对本档上文的影响**:§七 第 1 条(钩子只判"有没有锁"、**不判"锁是不是你的"**)在 R9 下更关键 ——
无锁时 hook 两边都拦(天然串行化),但**有锁时它不判归属**,所以"这是别人的锁"仍**只能靠 guard + R9 拦人**,
这正是本次把措辞从"可以删锁接管"改成"停手报告"的原因。
---
## 修正(2026-09-13):hook 路径随工作区迁移
工作区 2026-09-13 由 `D:\AI技能\aliyun-dsh-server` 迁至 **`E:\ProgramData\AI技能\aliyun-dsh-server`**;本档上文的 hook 命令片段与 `DSH_DOCS_ROOT` 默认值**已同步改为新路径**(实体在 `~/.workbuddy/settings.json` 与 `dsh-server-docs/scripts/lock-guard-hook.py`)。代码仓 `D:\github\dsh_shenxian` 未变动。
**迁移时踩到的坑(值得记住)**:工作区一搬,`settings.json` 里 hooks 的**绝对路径**立刻失配 → `python` 打不开脚本 → **hook 报错 → 该机所有会话的 Write/Edit 全被拒**(2026-09-13 实际发生,连"改 `settings.json` 本身"都被拦)。
**语义定论(2026-09-13,含一次自我纠错)**:**hook 命令是「会话启动时快照」** —— 本会话 06:47 启动 → 06:55 把 `settings.json` 的 hook 路径改成 E: → **07:05 拆掉临时目录联接后,Write/Edit 报的仍是旧路径 `D:/AI技能/...`** ⇒ 改配置**对已在跑的会话无效**。
⚠️ 期间一度写成「每次调用现读、无需重启」并据此宣布「目录联接多余」—— 实为**联接在 06:53–07:05 存在**,把「改完就能写」伪装成了现读(同机另一会话也据此得出同样错论,可见**这就是该误判的成因**)。
**正确处置**:改完 hook 路径 → **完全重启 WorkBuddy**(关窗 ≠ 退出)或**新开会话**;重启前该机所有会话的写操作一直被拒。**应急兜底**:本钩子**不拦 Bash**(有意留的安全阀)⇒ 可用 shell 写文件过渡(09-13 实际走通)。
(临时目录联接已按用户要求移除;`D:/AI技能` 现无任何实体目录或联接。)