fix(task-worktree): build/bench 分锁 TTL + done 自动接管陈旧锁

A:LOCK_TTL_HOURS 由全局 6h 改为按锁分设(build 1h / bench 6h)——
   兜底窗口应与操作真实时长同量级:编译是秒~分钟级,6h 太钝。
   并支持 lock acquire --ttl 自报窗口(keil_build 按其 --timeout 自报,
   timeout/3600+15min,下限 0.5h),使 build 锁窗口自动跟随构建上限。
B:done 收口前置把陈旧锁视为空闲并自动释放(原为硬失败),
   仅当锁仍被有效占用时才拒绝。

新增 lock_ttl_hours / lock_is_stale / release_stale_locks 三个辅助;
lock status 显示各锁 TTL;cmd_done 改调 release_stale_locks。

验证:20 项 TTL 探针 + 5 项 lock_cli 接线探针全绿;
假 UV4 端到端(exit 0 自持自释、payload ttl=0.5)与 busy 路径(exit 4)。
This commit is contained in:
2026-10-12 18:59:49 +08:00
parent 3c7ad9c75c
commit e5d3dc1205
5 changed files with 119 additions and 31 deletions
+8 -3
View File
@@ -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)。
@@ -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 <ID>")
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)