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
+23 -1
View File
@@ -18,7 +18,29 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0
解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测): 解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测):
工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` > 工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` >
`$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。 `$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。
退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ `2`=UV4 未找到/无效 / `3`=用法。 退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ `2`=UV4 未找到/无效 / `3`=用法 /
`4`=build 锁被占用(并发 `-b` 不可行)。
## build 锁:脚本自持自释(2026-10-12)
**UV4 不能两个实例同时 `-b`,但构建锁不该由调用方"记得释放"**——那正是泄漏源:
Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 6 小时 TTL 才被接管。
故本脚本**自己**管:进入 acquire → `try` 构建 → `finally` **无条件** release
(构建失败、异常、`SystemExit` 都会释放)。
| 开关 | 作用 |
|---|---|
| `--lock-task <ID>` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 的 `_wt/<ID>-<slug>/` 反推 `<ID>` → `build-cli` |
| `--lock-wait <N>` | 锁被占时等待 N 秒(默认 0 = 立即失败,退出码 4) |
| `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) |
| `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) |
- 锁工具 = 兄弟 skill `task-worktree/scripts/task_worktree.py`;**找不到则降级为不加锁
并告警**(保持本 skill 可独立搬运)。
- ⚠ 从 `_wt/<ID>-<slug>/` 反推持有者名,意味着"手工 `lock acquire build --task <ID>`
后再跑 `@build`"仍**幂等**(同名即视为自己持有),不会自我死锁。
- **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨
烧录→`@test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。
## 本仓库 ## 本仓库
+128 -14
View File
@@ -6,6 +6,7 @@
python <skill>/scripts/keil_build.py python <skill>/scripts/keil_build.py
[--project X.uvprojx] [--uv4 UV4.exe] [--target 名字] [--project X.uvprojx] [--uv4 UV4.exe] [--target 名字]
[--log build_log.txt] [--timeout 600] [--log build_log.txt] [--timeout 600]
[--lock-task ID] [--lock-wait N] [--no-lock]
解析顺序(命令行 > 项目档案 > 环境 > 探测): 解析顺序(命令行 > 项目档案 > 环境 > 探测):
工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx 工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx
@@ -19,7 +20,21 @@
3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)" 3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)"
(0E0W 精确匹配同时把"只有警告"的构建判 FAIL) (0E0W 精确匹配同时把"只有警告"的构建判 FAIL)
退出码: 0=PASS(0错0警) 1=构建失败(看打印的报错行/日志) 2=UV4 未找到/无效 3=用法错误 **build 锁:本脚本自持自释(2026-10-12)**
UV4 不能两个实例同时 `-b`,所以构建锁不该由调用方"记得释放"——那正是泄漏源
(Agent 手工 `lock acquire` 后忘了 `release`,锁要挂到 6 小时 TTL 才被接管)。
改为:本脚本进入时 acquire、退出时**无条件** release(try/finally,构建失败/异常/
SystemExit 都会释放)。
- 锁工具 = 同级 skill `task-worktree/scripts/task_worktree.py`(**找不到则降级为
不加锁并告警**,保持本 skill 可移植)
- 持有者名 `--lock-task`:默认 `$DTU_TASK_ID` → 否则从 cwd 的 `_wt/<ID>-<slug>/`
反推 `<ID>`(与工作树命名约定一致,故"手工锁了同名任务"仍幂等)→ 否则 `build-cli`
- 被占用:默认**立即失败**(退出码 4,不静默等待);`--lock-wait N` 可等待 N 秒
- 调用方**已持有** build 锁时(如 `task-worktree done` 的门禁),设
`DTU_LOCK_HELD=build` 让本脚本跳过自持自释,避免自我死锁
退出码: 0=PASS(0错0警) 1=构建失败(看打印的报错行/日志) 2=UV4 未找到/无效
3=用法错误 4=build 锁被占用(并发 -b 不可行)
""" """
import argparse import argparse
@@ -30,6 +45,8 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
LOCK_NAME = "build"
def find_config_file(): def find_config_file():
env = os.environ.get("DSH_SERIAL_CONFIG") env = os.environ.get("DSH_SERIAL_CONFIG")
@@ -101,21 +118,74 @@ def find_project(cli_arg, cfg):
"\n ".join(str(h) for h in hits)) "\n ".join(str(h) for h in hits))
def main(argv=None): # --------------------------------------------------------------- build 锁(自持自释)
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)")
ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)")
ap.add_argument("--uv4", default=None, help="UV4.exe path")
ap.add_argument("--target", default=None, help="build target name (-t; default: project default)")
ap.add_argument("--log", default=None, help="build log path (default: <proj dir>/build_log.txt)")
ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds")
args = ap.parse_args(argv)
def find_lock_tool():
"""定位 task-worktree 的 lock CLI(同级 skill)。找不到 → None(降级为不加锁)。
keil-build 与 task-worktree 是兄弟目录:.dsh/skills/{keil-build,task-worktree}/
故从本文件上溯 2 级即到 skills/。找不到不报错——保持本 skill 可独立搬运。
"""
try:
skills = Path(__file__).resolve().parents[2]
except IndexError:
return None
cand = skills / "task-worktree" / "scripts" / "task_worktree.py"
return cand if cand.is_file() else None
def default_lock_task():
"""持有者名:$DTU_TASK_ID > cwd 反推 `_wt/<ID>-<slug>/` 的 `<ID>` > "build-cli"。"""
v = os.environ.get("DTU_TASK_ID")
if v:
return v
try:
cwd = Path.cwd().resolve()
except OSError:
return "build-cli"
for p in (cwd, *cwd.parents):
if p.parent.name == "_wt":
return p.name.split("-", 1)[0] or p.name
return "build-cli"
def lock_cli(tool, action, task, wait=0, force=False):
"""调 task-worktree 的 `lock <action> build --task <task>`。返回 (ok, 输出)。"""
cmd = [sys.executable, str(tool), "lock", action, LOCK_NAME, "--task", task]
if action == "acquire" and wait > 0:
cmd += ["--wait", str(wait)]
if force:
cmd += ["--force"]
try:
r = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL)
except OSError as e:
return False, "lock CLI 调用失败:{}".format(e)
return r.returncode == 0, (r.stdout + r.stderr).strip()
def acquire_build_lock(args):
"""返回 (state, tool, task, note)。state ∈ ok / caller / skipped / busy。"""
if args.no_lock:
return "skipped", None, None, "--no-lock"
held = [x.strip() for x in (os.environ.get("DTU_LOCK_HELD") or "").split(",")
if x.strip()]
if LOCK_NAME in held:
return "caller", None, None, "调用方已持有(DTU_LOCK_HELD)"
tool = find_lock_tool()
if tool is None:
return "skipped", None, None, "未找到 task-worktree skill"
task = args.lock_task or default_lock_task()
ok, out = lock_cli(tool, "acquire", task, wait=args.lock_wait)
if not ok:
return "busy", tool, task, out
return "ok", tool, task, ""
def run_build(args):
"""真正的构建主体(锁由 main 负责)。返回退出码。"""
cfg, err = load_config() cfg, err = load_config()
if err: if err:
print("BUILD_ENV_ERROR: {}".format(err)) print("BUILD_ENV_ERROR: {}".format(err))
@@ -184,5 +254,49 @@ def main(argv=None):
return 0 return 0
def main(argv=None):
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)")
ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)")
ap.add_argument("--uv4", default=None, help="UV4.exe path")
ap.add_argument("--target", default=None, help="build target name (-t; default: project default)")
ap.add_argument("--log", default=None, help="build log path (default: <proj dir>/build_log.txt)")
ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds")
ap.add_argument("--lock-task", default=None,
help="build 锁持有者名 (default: $DTU_TASK_ID / _wt/<ID>-* / build-cli)")
ap.add_argument("--lock-wait", type=int, default=0,
help="build 锁等待秒数 (0=不等待,占用即退出码 4)")
ap.add_argument("--no-lock", action="store_true",
help="完全不加 build 锁(危险:并发 -b 会互相干扰)")
args = ap.parse_args(argv)
state, tool, task, note = acquire_build_lock(args)
if state == "busy":
print("BUILD_LOCK_BUSY: build 锁被占用(UV4 不能并发构建)")
if note:
for ln in note.splitlines():
print("BUILD_LOCK_BUSY: {}".format(ln))
print("BUILD_LOCK_BUSY: 用 --lock-wait N 等待,或等对方 release")
return 4
if state == "ok":
print("LOCK: 已获取 build 锁(task={})".format(task))
elif state == "caller":
print("LOCK: 由调用方持有,跳过自持自释")
else:
print("WARN: 本次构建**未加 build 锁**({})——并发 -b 会互相干扰".format(note))
try:
return run_build(args)
finally:
if state == "ok":
ok, out = lock_cli(tool, "release", task)
print("LOCK: {}".format(out or ("已释放 build 锁" if ok else "释放 build 锁失败")))
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+8 -1
View File
@@ -55,8 +55,11 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。 1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。
2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。 2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。
3. **`build` 与 `bench` 都必须持锁**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、 3. **`build` 与 `bench` 都必须串行**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、
设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。** 设备 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`、 4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、
`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。 `.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。
5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再 5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再
@@ -103,6 +106,10 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
所以判「陈旧」只看**时效**(默认 6 小时,`LOCK_TTL_HOURS`)加显式 `--force`;pid 仅作排障记录。 所以判「陈旧」只看**时效**(默认 6 小时,`LOCK_TTL_HOURS`)加显式 `--force`;pid 仅作排障记录。
同任务重复 acquire 是幂等的(不会把自己锁死)。 同任务重复 acquire 是幂等的(不会把自己锁死)。
- **陈旧锁可自动接管**:超过 6 小时未释放即视为陈旧,下次 acquire 会接管并提示;活锁需 `--force`。 - **陈旧锁可自动接管**:超过 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 的衔接 ## 与其它 skill 的衔接
@@ -379,11 +379,16 @@ def run_gates(cwd: Path, skip_build: bool) -> bool:
if not got: if not got:
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task"))) print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task")))
return False return False
# 告知 @build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
os.environ["DTU_LOCK_HELD"] = "build"
try: try:
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd, if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
"@build(0 错 0 警)"): "@build(0 错 0 警)"):
ok = False ok = False
finally: finally:
os.environ.pop("DTU_LOCK_HELD", None)
lock_release("build", "__done__", force=True) lock_release("build", "__done__", force=True)
return ok return ok