Files
workbuddy_skills/session-mechanism/scripts/lock/orphan-lock.py
T

248 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:
"""把本包 `env/roots.env` 以 setdefault 注入 env(宿主 env 优先,⛔ 不覆盖)。"""
here = os.path.dirname(os.path.abspath(__file__))
cands = []
for up in range(4):
base = os.path.normpath(os.path.join(here, *([".."] * up)))
for rel in (("env", "roots.env"), ("roots.env",)):
cands.append(os.path.join(base, *rel))
conf = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")
cands.append(os.path.join(conf, "skills", "session-mechanism", "env", "roots.env"))
for p in cands:
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) / "交接单"
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())