diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index c8b40f70..3862b8fc 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -29,7 +29,7 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 ## build 锁:脚本自持自释(2026-10-12) **UV4 不能两个实例同时 `-b`,但构建锁不该由调用方"记得释放"**——那正是泄漏源: -Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 6 小时 TTL 才被接管。 +Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被接管。 故本脚本**自己**管:进入 acquire → `try` 构建 → `finally` **无条件** release (构建失败、异常、`SystemExit` 都会释放)。 @@ -40,6 +40,9 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 6 小时 TT | `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) | | `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) | +- **TTL 自报**(2026-10-12):acquire 时按 `--timeout` 声明陈旧窗口 + = `timeout/3600 + 15min`,下限 0.5h(默认 600s → **0.5h**)。这样崩溃/强杀留下的锁, + 其自动接管窗口与本次构建上限同量级,而不再是全局 6h —— 编译很快,兜底窗口也该短。 - 锁工具 = 兄弟 skill `task-worktree/scripts/task_worktree.py`;**找不到则降级为不加锁 并告警**(保持本 skill 可独立搬运)。 - ⚠ 从 `_wt/-/` 反推持有者名,意味着"手工 `lock acquire build --task ` diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py index 8657ea13..8baf8571 100644 --- a/.dsh/skills/keil-build/scripts/keil_build.py +++ b/.dsh/skills/keil-build/scripts/keil_build.py @@ -22,7 +22,7 @@ **build 锁:本脚本自持自释(2026-10-12)** UV4 不能两个实例同时 `-b`,所以构建锁不该由调用方"记得释放"——那正是泄漏源 - (Agent 手工 `lock acquire` 后忘了 `release`,锁要挂到 6 小时 TTL 才被接管)。 + (Agent 手工 `lock acquire` 后忘了 `release`,锁要挂到 TTL 才被接管)。 改为:本脚本进入时 acquire、退出时**无条件** release(try/finally,构建失败/异常/ SystemExit 都会释放)。 - 锁工具 = 同级 skill `task-worktree/scripts/task_worktree.py`(**找不到则降级为 @@ -32,6 +32,9 @@ - 被占用:默认**立即失败**(退出码 4,不静默等待);`--lock-wait N` 可等待 N 秒 - 调用方**已持有** build 锁时(如 `task-worktree done` 的门禁),设 `DTU_LOCK_HELD=build` 让本脚本跳过自持自释,避免自我死锁 + - **TTL 自报**:acquire 时按 `--timeout` 声明陈旧窗口(`timeout/3600 + 15min`, + 下限 0.5h)。故崩溃/强杀留下的锁,其自动接管窗口与本次构建上限同量级, + 而不再是全局 6h(编译很快,兜底窗口也该短)。 退出码(与 task-worktree 同一约定;2026-10-12 修正——此前实现与文档不符): 0 = PASS(0 错 0 警) @@ -174,11 +177,26 @@ def default_lock_task(): return "build-cli" -def lock_cli(tool, action, task, wait=0, force=False): +def build_lock_ttl_hours(timeout_s): + """build 锁自报的陈旧窗口(小时)≈ UV4 超时上限 + 15min 余量,下限 0.5h。 + + 这样「崩溃/被强杀」留下的锁,其自动接管窗口与本次构建的真实上限同量级, + 而不是全局 6h —— 编译很快,锁的兜底窗口也该短。 + """ + try: + t = max(0.0, float(timeout_s)) + except (TypeError, ValueError): + t = 600.0 + return max(0.5, t / 3600.0 + 0.25) + + +def lock_cli(tool, action, task, wait=0, force=False, ttl=None): """调 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 action == "acquire" and ttl: + cmd += ["--ttl", "{:.4f}".format(ttl)] if force: cmd += ["--force"] try: @@ -202,7 +220,8 @@ def acquire_build_lock(args): 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) + ok, out = lock_cli(tool, "acquire", task, wait=args.lock_wait, + ttl=build_lock_ttl_hours(args.timeout)) if not ok: return "busy", tool, task, out return "ok", tool, task, "" diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md index fc37d15f..d7cad2eb 100644 --- a/.dsh/skills/task-worktree/SKILL.md +++ b/.dsh/skills/task-worktree/SKILL.md @@ -103,11 +103,16 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突; - **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB, 两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean`。 - **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。 - 所以判「陈旧」只看**时效**(默认 6 小时,`LOCK_TTL_HOURS`)加显式 `--force`;pid 仅作排障记录。 + 所以判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。 同任务重复 acquire 是幂等的(不会把自己锁死)。 -- **陈旧锁可自动接管**:超过 6 小时未释放即视为陈旧,下次 acquire 会接管并提示;活锁需 `--force`。 +- **TTL 按锁分设**(2026-10-12):`build` = **1h**、`bench` = **6h**(`LOCK_TTL_HOURS`)。 + 口径是「兜底窗口应与操作真实时长同量级」——编译是秒~分钟级,6h 的兜底太钝; + bench 是会话级(flash→`@test`→探针),才配得上小时级。持有者还可用 + `lock acquire ... --ttl <小时>` **自报**更精确的窗口(`keil_build.py` 即按其 `--timeout` 自报)。 +- **陈旧锁自动接管**:超过该锁 TTL 未释放即视为陈旧,下次 `acquire` 会接管并提示; + 未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败)。 - **`build` 锁不必手工持**(2026-10-12 起):`@build`(`keil_build.py`)**自持自释**—— - 进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release 泄漏 6 小时"这条在构建路径上已消除。 + 进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release、锁一直挂到 TTL"这条在构建路径上已消除。 `done` 的门禁自己持锁并给子进程设 `DTU_LOCK_HELD=build` 告知跳过(新旧脚本都兼容)。 **`bench` 锁仍须 Agent 手工持**(会话作用域:跨烧录→`@test`→探针,见 `keil-build` SKILL.md)。 diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py index 128bc0a1..6f5ab81d 100644 --- a/.dsh/skills/task-worktree/scripts/task_worktree.py +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -29,7 +29,14 @@ EXIT_OK, EXIT_FAIL, EXIT_ENV, EXIT_USAGE = 0, 1, 2, 3 BRANCH_PREFIX = "task/" MAX_CONCURRENT = 3 LOCK_NAMES = ("build", "bench") -LOCK_TTL_HOURS = 6.0 # 超过此持有时间即视为陈旧锁,下次 acquire 自动接管 +# 按锁分设陈旧窗口(小时)——兜底窗口应与「操作真实时长」同量级: +# build:编译是秒~分钟级(keil_build.py 自持自释,持有窗口 ≈ UV4 运行时长, +# 上限 = --timeout,默认 600s);崩溃泄漏时应在数十分钟内即可接管 → 1h。 +# bench:会话级(flash → @test → 探针,天然几十分钟~数小时)→ 保留 6h。 +# 另:锁 payload 里持有者可用 `ttl` 字段自报窗口(见 lock_acquire(ttl=...)), +# 优先于这里的默认值——这样 build 锁的窗口能自动跟随其 --timeout。 +LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0} +DEFAULT_LOCK_TTL_HOURS = 6.0 JUNCTION_SPECS = ( (".venv", "Python 环境"), ("simulator/SDL2", "模拟器 SDL2 SDK"), @@ -212,6 +219,26 @@ def lock_age_hours(info) -> float: return float("inf") +def lock_ttl_hours(name: str, info=None) -> float: + """锁 `name` 的陈旧判定窗口(小时)。 + + 优先用锁 payload 里持有者自报的 `ttl`(例如 keil_build.py 按其 --timeout 声明), + 否则回落到该锁的默认窗口(build 1h / bench 6h)。 + """ + if info: + try: + v = info.get("ttl") + if v: + return max(0.0, float(v)) + except (TypeError, ValueError): + pass + return LOCK_TTL_HOURS.get(name, DEFAULT_LOCK_TTL_HOURS) + + +def lock_is_stale(name: str, info) -> bool: + return lock_age_hours(info) > lock_ttl_hours(name, info) + + def lock_path(name: str) -> Path: return LOCKS_DIR / (name + ".lock") @@ -226,12 +253,14 @@ def lock_read(name: str): return {"task": "?", "pid": 0, "epoch": 0, "at": "?"} -def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: str = ""): +def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: str = "", + ttl: float = None): """抢占独占资源。 ⚠ 锁**不**绑定进程存活:`lock acquire` 本身是短命进程,而用锁的烧录/构建 - 在另一个进程里跑。因此判「陈旧」只看**时效**(超过 LOCK_TTL_HOURS), - 外加显式 `--force`;pid 仅作记录便于排障。 + 在另一个进程里跑。因此判「陈旧」只看**时效**(超过该锁的 TTL),外加显式 + `--force`;pid 仅作记录便于排障。TTL 按锁分设(build 1h / bench 6h), + 持有者也可用 `ttl`(小时)自报更精确的窗口(如 build 按 --timeout 声明)。 """ LOCKS_DIR.mkdir(parents=True, exist_ok=True) p = lock_path(name) @@ -244,9 +273,10 @@ def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: if info.get("task") == task: return True, info age = lock_age_hours(info) - if force or age > LOCK_TTL_HOURS: - print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时{})".format( - name, info.get("task"), age, ",--force" if force else ",视为陈旧")) + ttl_h = lock_ttl_hours(name, info) + if force or age > ttl_h: + print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时 / TTL {:.1f} 小时{})".format( + name, info.get("task"), age, ttl_h, ",--force" if force else ",视为陈旧")) try: p.unlink() except OSError: @@ -254,8 +284,8 @@ def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: continue if time.time() >= deadline: return False, info - print("[lock] {} 被 {} 占用(已持有 {:.1f} 小时),等待…".format( - name, info.get("task"), age)) + print("[lock] {} 被 {} 占用(已持有 {:.1f} / TTL {:.1f} 小时),等待…".format( + name, info.get("task"), age, ttl_h)) time.sleep(2) else: now = time.time() @@ -265,6 +295,7 @@ def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: "epoch": now, "at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)), "note": note, + "ttl": ttl, } os.write(fd, (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")) os.close(fd) @@ -631,13 +662,14 @@ def cmd_lock(a) -> int: for n in LOCK_NAMES: info = lock_read(n) if not info: - print("{:<6} 空闲".format(n)) + print("{:<6} 空闲(TTL {:.1f}h)".format(n, lock_ttl_hours(n))) else: age = lock_age_hours(info) - mark = " [陈旧,可被自动接管]" if age > LOCK_TTL_HOURS else "" - print("{:<6} {} 持有(pid={},于 {},已 {:.1f} 小时,备注 {}){}".format( + ttl_h = lock_ttl_hours(n, info) + mark = " [陈旧,可被自动接管]" if age > ttl_h else "" + print("{:<6} {} 持有(pid={},于 {},已 {:.1f} / TTL {:.1f} 小时,备注 {}){}".format( n, info.get("task"), info.get("pid"), info.get("at"), age, - info.get("note") or "-", mark)) + ttl_h, info.get("note") or "-", mark)) return EXIT_OK if a.name not in LOCK_NAMES: @@ -646,12 +678,15 @@ def cmd_lock(a) -> int: if a.action == "acquire": if not a.task: raise EnvError("acquire 需要 --task ") - got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force, note=a.note or "") + got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force, + note=a.note or "", ttl=a.ttl) if not got: print("获取失败:{} 被 {} 占用(pid={},于 {})".format( a.name, info.get("task"), info.get("pid"), info.get("at"))) return EXIT_FAIL - print("已获取 {} 锁(task={} pid={})".format(a.name, a.task, os.getpid())) + print("已获取 {} 锁(task={} pid={} ttl={})".format( + a.name, a.task, os.getpid(), + "{:.2f}h".format(a.ttl) if a.ttl else "默认")) return EXIT_OK if a.bench_pass: @@ -666,6 +701,27 @@ def cmd_lock(a) -> int: return EXIT_OK if ok else EXIT_FAIL +def release_stale_locks() -> None: + """`done` 前置:陈旧锁自动接管;仍被有效占用的则拒绝收口。 + + 「陈旧」= 持有时间 > 该锁 TTL(build 1h / bench 6h,或持有者自报的 ttl)。 + 崩溃/遗忘留下的锁不该阻塞收口——故自动释放而非硬失败(2026-10-12)。 + """ + for n in LOCK_NAMES: + info = lock_read(n) + if not info: + continue + age = lock_age_hours(info) + ttl_h = lock_ttl_hours(n, info) + if age > ttl_h: + ok, msg = lock_release(n, force=True) + print("[i] {} 锁已陈旧({} 持有 {:.1f}h > TTL {:.1f}h),自动接管:{}".format( + n, info.get("task"), age, ttl_h, msg)) + continue + raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} / TTL {:.1f} 小时),先 release".format( + n, info.get("task"), age, ttl_h)) + + def cmd_done(a) -> int: st = load_state(a.task_id) wt = Path(st["path"]) @@ -683,11 +739,7 @@ def cmd_done(a) -> int: cur = git_out("rev-parse", "--abbrev-ref", "HEAD", cwd=main) if cur != base: raise Fail("主工作树当前在 {},不是集成分支 {};先切过去再 done".format(cur, base)) - for n in LOCK_NAMES: - info = lock_read(n) - if info: - raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} 小时),先 release".format( - n, info.get("task"), lock_age_hours(info))) + release_stale_locks() ahead = int(git_out("rev-list", "--count", "{}..{}".format(base, branch), cwd=wt) or "0") already = git("merge-base", "--is-ancestor", branch, base, cwd=main, check=False).returncode == 0 @@ -816,6 +868,8 @@ def build_parser(): p.add_argument("--wait", type=int, default=0, help="等待秒数(0=不等待)") p.add_argument("--force", action="store_true") p.add_argument("--note", default="") + p.add_argument("--ttl", type=float, default=None, + help="本次持有自报的陈旧窗口(小时);缺省用该锁默认值(build 1h / bench 6h)") p.add_argument("--bench-pass", action="store_true", help="release bench 时顺带为该任务记录台架 PASS") p.set_defaults(func=cmd_lock) diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md index 224ef741..93aeb231 100644 --- a/docs/环境与部署/并行任务工作树方案.md +++ b/docs/环境与部署/并行任务工作树方案.md @@ -180,13 +180,20 @@ D:/Code/DTU Code/ - ST-LINK SN `0B250D12A218303030303032` - 设备 IP `192.168.1.100` -机制:`_wt/.state/locks/{build,bench}.lock` 记录持有者(任务 ID + pid + 时间戳 + 备注)。 +机制:`_wt/.state/locks/{build,bench}.lock` 记录持有者(任务 ID + pid + 时间戳 + 备注 + 自报 TTL)。 **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在另一个进程里跑。 若按 pid 判活,acquire 一退出锁就被判为陈旧 → 锁形同虚设(实测踩到)。 -故判「陈旧」只看**时效**(`LOCK_TTL_HOURS` = 6 小时)加显式 `--force`;pid 仅作排障记录。 +故判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。 同任务重复 acquire 幂等;他人持锁时 acquire 拒绝(`--wait` 可轮询等待)。 -`done` 前置检查会在任一锁被占用时拒绝回收。 + +**TTL 按锁分设**(2026-10-12):`LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}`。 +口径 = 兜底窗口应与**操作真实时长**同量级:编译是秒~分钟级(`keil_build.py` 自持自释, +窗口 ≈ UV4 运行时长),1h 已足够宽松;bench 是**会话级**(flash→`@test`→探针),才配得上 6h。 +持有者还可用 `lock acquire ... --ttl <小时>` **自报**更精确的窗口—— +`keil_build.py` 即按其 `--timeout` 自报(`timeout/3600 + 15min`,下限 0.5h)。 + +`done` 前置检查:**陈旧锁自动接管释放**(不再硬失败),仅当锁仍被有效占用时才拒绝回收。 > ⚠ 台架相关的既有陷阱(`--require-selftest-pass` 的陈旧台账、回读校验会复位目标) > 不因引入工作树而改变,仍按 `docs/测试说明/自动化测试流程.md` 执行。 @@ -366,7 +373,7 @@ docs\内存管理\RAM使用分析与优化方案.md:585 [路径引用] docs/内 **实测暴露并已修复的 4 个缺陷**: 1. **锁绑定进程存活 → 形同虚设**:`lock acquire` 是短命进程,一退出锁就被判陈旧、下次必被接管。 - 改为**时效判定**(6 小时 TTL)+ 显式 `--force`,pid 仅作记录。 + 改为**时效判定**(当时全局 6h;2026-10-12 起改为按锁分设 TTL,见 §7.1)+ 显式 `--force`,pid 仅作记录。 2. **`git branch -d` 按环境 HEAD 判「已合并」**:集成点被改走时误判、拒绝删除。 改为显式 `merge-base --is-ancestor ` 后再删。 3. **直调 Win32 建联接被拒**(Win32 4392):改走 `mklink /J`(详见 3.3)。