Files

240 lines
9.9 KiB
Python
Raw Permalink Normal View History

#!/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 行记着 `会话:<sid>`(`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())