#!/usr/bin/env python3 # -*- coding: utf-8 -*- """orphan-lock.py — 判定「孤儿锁」并解除(🔴 用户 2026-10-09 授权:检查会话可自解除)。 【这条授权改了什么】 R9 原口径(用户 2026-09-12):「AI 一律不得删锁、不得以持有者为由单方面接管」。 它留下一个死局:持有者会话被强杀 ⇒ 锁永远留在盘上 ⇒ 后续任何会话抢都失败 ⇒ 目标卡死,只能等用户本人到场。 2026-10-09 实测坐实:`contentm_agent` 一把孤儿锁把第 4 步挡住,机制空转 11 棒(约 5 小时)。 同日用户逐字授权: 「如果 工作区 项目机制 对应的会话异常 导致未解锁的情况下, 可以授权 检查会话自解除孤儿锁」 ⇒ R9 的**唯一例外**=本脚本覆盖的这一个情形:**锁的持有者会话已退出、不可能再来释放**。 【判据(⛔ 不是"看它像不像死的",而是查持有者的真身状态)】 锁目录 `OWNER` 第 3 行记着 `会话:`(`handoff-guard.sh` 抢锁时写入)。 拿 sid 去宿主库 `sessions` 表查 `status`: · `working` ⇒ **持有者还活着 ⇒ 不动**(R9 原样) · `terminated` / `error` / `archived` ⇒ 异常退出/已归档 ⇒ **可解除** · `completed` ⇒ 已收工;再要一道时间闸(`--idle-min`,默认 30 分钟) 内无活动 ⇒ 可解除 ⚠️ 这道时间闸只为不掐掉「刚收工、用户随时会接着聊」的会话;但它**已经不在执行**了 ⇒ 它**也不会再来释放** ⇒ 锁同样已成孤儿。 · 查不到该 sid/库读不到/`OWNER` 里没记 sid ⇒ **一律不动**(fail-closed) 🔴 判据只此一份,⛔ 不靠人目测 `OWNER` 文件猜死活。 【用法】 python orphan-lock.py # 判定并解除(默认执行) python orphan-lock.py --dry-run # 只看判定,一把锁都不动 python orphan-lock.py --idle-min 60 # 改 `completed` 档的时间闸(分钟) python orphan-lock.py --locks-root <目录> # 改锁根(自测用) 退出码:0 = 没有孤儿锁(或已全部解除)|1 = 有锁未动(持有者活着/判不出)|2 = 参数错 """ from __future__ import annotations import argparse import os import re import shutil import sqlite3 import sys import time from pathlib import Path # ── 根目录定位(与包内其它脚本同款:宿主 env > `roots.env` 兜底)──────────── def _sm_load_roots() -> None: here = os.path.dirname(os.path.abspath(__file__)) for up in range(4): p = os.path.normpath(os.path.join(here, *([".."] * up), "roots.env")) if os.path.isfile(p): try: with open(p, encoding="utf-8") as f: for ln in f: ln = ln.strip() if ln and not ln.startswith("#") and "=" in ln: k, v = ln.split("=", 1) os.environ.setdefault(k.strip(), v.strip()) except Exception: pass return _sm_load_roots() # 授权放行的三档(持有者不可能再来释放) DEAD_STATUS = ("terminated", "error", "archived") # 需要再加一道时间闸的一档(已收工,但可能刚收工) IDLE_STATUS = ("completed",) ALIVE_STATUS = ("working",) SID_RE = re.compile(r"^会话[::]\s*([0-9A-Za-z][0-9A-Za-z\-]{7,})\s*$", re.M) def _host_db() -> str: """宿主库路径:`DSH_HOST_DB` > `CODEBUDDY_CONFIG_DIR/workbuddy.db` > `~/.workbuddy/workbuddy.db`。""" p = (os.environ.get("DSH_HOST_DB") or "").strip() if p and os.path.isfile(p): return p cands = [] cfg = (os.environ.get("CODEBUDDY_CONFIG_DIR") or "").strip() if cfg: cands.append(os.path.join(cfg, "workbuddy.db")) cands.append(os.path.join(os.path.expanduser("~"), ".workbuddy", "workbuddy.db")) for c in cands: if os.path.isfile(c): return c return "" def _sess_row(db: str, sid: str): """返回 `(status, last_activity_at_ms)`;⛔ 任何异常 ⇒ `None`(调用方按 fail-closed 处理)。""" if not db: return None try: con = sqlite3.connect("file:%s?mode=ro" % db.replace("\\", "/"), uri=True, timeout=5) try: cur = con.cursor() cur.execute("select status, last_activity_at from sessions where id like ?", (sid.rstrip() + "%",)) r = cur.fetchone() finally: con.close() if not r: return None return (str(r[0] or "").strip().lower(), r[1]) except Exception: return None def _judge(status: str, last_act, idle_min: float): """→ `(可解除?, 说明)`。判据唯一实现,⛔ 别在别处另写一份。""" if not status: return False, "判不出(宿主库无此会话)⇒ 按 R9 不动" if status in ALIVE_STATUS: return False, "持有者仍 `working`(活着)⇒ 按 R9 不动" if status in DEAD_STATUS: return True, "持有者 `%s`(已退出,不会再来释放)" % status if status in IDLE_STATUS: try: ms = int(last_act or 0) except Exception: ms = 0 if not ms: return False, "`completed` 但读不到最后活动时刻 ⇒ 不动(fail-closed)" idle = (time.time() * 1000 - ms) / 60000.0 if idle >= idle_min: return True, "持有者 `completed` 且已静默 %.0f 分钟(≥%d)" % (idle, idle_min) return False, "持有者 `completed`,但仅静默 %.0f 分钟(<%d)⇒ 可能马上续聊,不动" % (idle, idle_min) return False, "持有者状态 `%s` 不在已知档 ⇒ 不动(fail-closed)" % status def _scan(root: Path, exec_lock: Path, dry: bool, idle_min: float, db: str, loglines: list): left = 0 items = [] if root.is_dir(): for L in sorted(root.iterdir()): if not L.is_dir() or L.name in (".gate", ".migrations"): continue items.append(L) if exec_lock.is_dir(): items.append(exec_lock) if not items: print("· 锁目录里一把锁都没有(%s)" % root) return 0 for L in items: try: txt = (L / "OWNER").read_text(encoding="utf-8", errors="replace") except Exception as e: print("· %-42s | 读不到 OWNER(%s)⇒ 不动" % (L.name, e)) left += 1 continue owner = (txt.splitlines() or ["?"])[0].strip() m = SID_RE.search(txt) if not m: print("· %-42s | 持有者 %-24s | ⛔ OWNER 里没记会话 id ⇒ 不动(fail-closed)" % (L.name[:42], owner[:24])) left += 1 continue sid = m.group(1) row = _sess_row(db, sid) status, last_act = (row if row else ("", None)) ok, why = _judge(status, last_act, idle_min) tag = "⇒ 解除" if ok else "⇒ 不动" if ok and not dry: try: shutil.rmtree(L) except Exception as e: print("· %-42s | 持有者 %-24s | 解除失败:%s" % (L.name[:42], owner[:24], e)) left += 1 continue loglines.append("%s\t释放\t%s\t%s\t%s\t%s" % (time.strftime("%Y-%m-%dT%H:%M:%S"), L.name, owner, sid, status)) elif ok and dry: tag = "⇒ 解除(dry-run,未动)" if not ok: left += 1 print("· %-42s | 持有者 %-24s | sid %s | %-11s | %s" % (L.name[:42], owner[:24], sid[:8], status or "?", tag + ":" + why)) return left def main() -> int: try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass ap = argparse.ArgumentParser(add_help=True) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--idle-min", type=float, default=30.0) ap.add_argument("--locks-root", default="") a = ap.parse_args() docs = (os.environ.get("DSH_DOCS_ROOT") or "").strip() if a.locks_root: root = Path(a.locks_root) exec_lock = root / ".exec-lock" elif docs and os.path.isdir(docs): base = Path(docs) / "05-交接单" root = base / ".locks" exec_lock = base / ".exec-lock" else: print("✗ 定位不到文档库根(`DSH_DOCS_ROOT`)—— ⛔ 一把锁都没动。" "请在有 `roots.env` 的环境里跑,或用 `--locks-root` 指定锁根。", file=sys.stderr) return 1 db = _host_db() print("锁根:%s" % root) print("宿主库:%s%s" % (db or "(未找到)", "" if db else " ⇒ 查不到会话状态,将一律不动")) print("授权来源:用户 2026-10-09「可以授权 检查会话自解除孤儿锁」(R9 的唯一例外)") print("执行者:%s / %s" % (os.environ.get("DSH_SESSION_NAME") or "?", (os.environ.get("CODEBUDDY_SESSION_ID") or "?")[:8])) print("-" * 96) logs: list = [] left = _scan(root, exec_lock, a.dry_run, a.idle_min, db, logs) if logs and not a.dry_run: try: lp = root / ".orphan-release.log" with open(lp, "a", encoding="utf-8", newline="\n") as f: f.write("".join(x + "\n" for x in logs)) print("-" * 96) print("已记流水:%s" % lp) except Exception: pass print("-" * 96) if left: print("结论:另有 %d 把锁**未动**(持有者活着/判不出)—— 那是 R9 的地盘,⛔ 不许绕。" "若确实卡住,写 `tmp/supervise-inbox/NEED-USER.md` 并明确喊「需用户介入」。" % left) return 1 print("结论:没有孤儿锁(或在本次判定范围内已全部解除)。") return 0 if __name__ == "__main__": sys.exit(main())