fix(skill): @build 自持自释 build 锁,根治忘记 release 的泄漏

问题:锁不绑定进程存活(`lock acquire` 是短命进程,真正用 UV4 的是另一个
进程),所以释放只能靠调用方记得。Agent 手工 `lock acquire build` 后忘
了 `release`,锁要挂到 6 小时 TTL 才被下次 acquire 接管——期间其他任务
要么立即失败、要么干等。`done` 自己已用 try/finally 兜住,但手工路径没有。

改法:让**用资源的人**持锁。`keil_build.py` 进入 acquire、`finally`
**无条件** release(构建失败 / 异常 / SystemExit 都会释放)。
- `--lock-task`:持有者名。默认 `$DTU_TASK_ID` → 从 cwd 的
  `_wt/<ID>-<slug>/` 反推 `<ID>` → `build-cli`。反推意味着"手工
  `lock acquire build --task <ID>` 后再跑 @build"仍幂等,不会自我死锁。
- `--lock-wait N`:锁被占时等待(默认 0 = 立即失败)。
- `--no-lock`:完全不加锁。
- 新增退出码 **4 = build 锁被占用**(原先无此语义,占用会直接去构建)。
- 锁工具 = 兄弟 skill `task-worktree`;**找不到则降级为不加锁并告警**,
  保持本 skill 可独立搬运。

`done` 的兼容处理:它自己持锁,给子进程设 `DTU_LOCK_HELD=build` 告知
跳过自持自释。**对"工作树里还是旧 keil_build.py"的在途任务同样安全**
——旧版根本不看该变量、也不会自锁,故不会出现"双重持锁"死锁。

`bench` 锁**不动**:它是**会话作用域**(跨烧录→@test→探针多条命令),
逐命令自动释放会让别的任务插进来扰动设备,必须继续由 Agent 持有。

验证(主工作树实测):
- 自持自释:acquire → BUILD_OK → release,前后 `lock status` 均空闲;
- 被占:由 other-task 占锁 → exit 4,且**未触发构建**;
- 调用方持锁:`DTU_LOCK_HELD=build` → 打印"跳过自持自释",且
  **未释放调用方的锁**(锁仍归 __done__)——关键安全性质;
- 错误路径:无调用方持锁 + `--project` 不存在 → 失败但 **finally 已释放**;
- 持有者名推导:主工作树 → `build-cli`,`_wt/N33-stack-margins` → `N33`;
- 门禁:`mem-numbers` 一致;pre-commit 全绿。
This commit is contained in:
2026-10-12 18:07:58 +08:00
parent 82cedcadb7
commit 1b450be0ef
4 changed files with 164 additions and 16 deletions
+8 -1
View File
@@ -55,8 +55,11 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。
2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。
3. **`build` 与 `bench` 都必须持锁**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、
3. **`build` 与 `bench` 都必须串行**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、
设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。**
- `build` 锁由 **`@build` 自持自释**,调用方无需手工 acquire/release;
- `bench` 锁**必须手工**:`lock acquire bench --task <ID>` → 烧录/`@test`/探针 →
`lock release bench --task <ID> --bench-pass`。
4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、
`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。
5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再
@@ -103,6 +106,10 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
所以判「陈旧」只看**时效**(默认 6 小时,`LOCK_TTL_HOURS`)加显式 `--force`;pid 仅作排障记录。
同任务重复 acquire 是幂等的(不会把自己锁死)。
- **陈旧锁可自动接管**:超过 6 小时未释放即视为陈旧,下次 acquire 会接管并提示;活锁需 `--force`。
- **`build` 锁不必手工持**(2026-10-12 起):`@build`(`keil_build.py`)**自持自释**——
进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release 泄漏 6 小时"这条在构建路径上已消除。
`done` 的门禁自己持锁并给子进程设 `DTU_LOCK_HELD=build` 告知跳过(新旧脚本都兼容)。
**`bench` 锁仍须 Agent 手工持**(会话作用域:跨烧录→`@test`→探针,见 `keil-build` SKILL.md)。
## 与其它 skill 的衔接
@@ -379,11 +379,16 @@ def run_gates(cwd: Path, skip_build: bool) -> bool:
if not got:
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task")))
return False
# 告知 @build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
os.environ["DTU_LOCK_HELD"] = "build"
try:
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
"@build(0 错 0 警)"):
ok = False
finally:
os.environ.pop("DTU_LOCK_HELD", None)
lock_release("build", "__done__", force=True)
return ok