diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index a84e07b2..ec2f8846 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -18,7 +18,29 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测): 工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` > `$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 ` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 的 `_wt/-/` 反推 `` → `build-cli` | +| `--lock-wait ` | 锁被占时等待 N 秒(默认 0 = 立即失败,退出码 4) | +| `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) | +| `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) | + +- 锁工具 = 兄弟 skill `task-worktree/scripts/task_worktree.py`;**找不到则降级为不加锁 + 并告警**(保持本 skill 可独立搬运)。 +- ⚠ 从 `_wt/-/` 反推持有者名,意味着"手工 `lock acquire build --task ` + 后再跑 `@build`"仍**幂等**(同名即视为自己持有),不会自我死锁。 +- **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨 + 烧录→`@test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。 ## 本仓库 diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py index a11ea3f7..b9c23b8b 100644 --- a/.dsh/skills/keil-build/scripts/keil_build.py +++ b/.dsh/skills/keil-build/scripts/keil_build.py @@ -6,6 +6,7 @@ python /scripts/keil_build.py [--project X.uvprojx] [--uv4 UV4.exe] [--target 名字] [--log build_log.txt] [--timeout 600] + [--lock-task ID] [--lock-wait N] [--no-lock] 解析顺序(命令行 > 项目档案 > 环境 > 探测): 工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx @@ -19,7 +20,21 @@ 3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)" (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/-/` + 反推 ``(与工作树命名约定一致,故"手工锁了同名任务"仍幂等)→ 否则 `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 @@ -30,6 +45,8 @@ import subprocess import sys from pathlib import Path +LOCK_NAME = "build" + def find_config_file(): 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)) -def main(argv=None): - for stream in (sys.stdout, sys.stderr): - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except Exception: - pass +# --------------------------------------------------------------- build 锁(自持自释) - 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: /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/-/` 的 `` > "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 build --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() if err: print("BUILD_ENV_ERROR: {}".format(err)) @@ -184,5 +254,49 @@ def main(argv=None): 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: /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/-* / 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__": sys.exit(main()) diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md index b8c53f84..fc37d15f 100644 --- a/.dsh/skills/task-worktree/SKILL.md +++ b/.dsh/skills/task-worktree/SKILL.md @@ -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 ` → 烧录/`@test`/探针 → + `lock release bench --task --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 的衔接 diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py index 7a7ab99c..128bc0a1 100644 --- a/.dsh/skills/task-worktree/scripts/task_worktree.py +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -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