240 lines
9.9 KiB
Python
240 lines
9.9 KiB
Python
#!/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())
|