Files
workbuddy_skills/session-mechanism/scripts/lock/orphan-lock.py
T
admin d81f75f173 技能包:配置与代码分家 —— 每包自建 env/ 放本机专属配置,scripts/ 不再写死本机路径
用户 2026-10-11 原话:
· 19:22「这个环境都是针对一台机器的,同步到别的机器把环境都搞坏了」
· 19:28「不用挪出 用env文件夹区分就行,重点是 脚本是脚本 配置是配置」
· 19:29「每个技能 需要环境配置的 都有自己的 env文件夹」
· 19:30「修改完成后要仔细检查 避免调整后 各技能脚本运行失败」

改动:
1、session-mechanism 新增 env/(roots.env=本机真身不入库 + roots.env.example=样例入库),
   install.py 的 ROOTS_ENV 改指 env/roots.env;全包脚本/hook 的配置定位统一为
   「向上 4 层 × env/roots.env 优先 → 包根 roots.env 兼容 → 技能库那份兜底」。
2、去硬编码:collabctl / keepalive / supervise-launch / board-launch 里的本机路径与本机工作区
   清单全清,改为 DSH_AIROOT(兜底 dirname(DSH_WS_ROOT))+ 扫 <AIROOT>/*/.workbuddy/collab/
   自动发现;保活任务名按 collabd-keepalive-<工作区名> 现推;启动器默认值改 ~/.workbuddy 形态。
3、browser-harness:bh_chrome_keepalive.py 默认值全部改 env 驱动,新增 env/bh.env.example。
4、.gitignore:env/ 下只放行 *.example(三行三种层级)+ 兜住旧位置的包根 roots.env。
5、改包纪律.md 新增「四、包结构:脚本是脚本,配置是配置」,立为全仓通用规矩。
6、文档同步:SKILL.md / deploy.md(新增 §3b 配置层级、§6 换机器必查 6 件事)/ rules.md /
   pitfalls.md / supervise-persistence.md。

验收(本机):py_compile 34 个 py 全过;selftest PASS 109 / FAIL 0;
逐文件实证 8 个 loader 命中 env/roots.env;preflight-lock.sh 同目录实测解出两个根;
install.py --manifest 77 份、语法失败 0。

异机验证(包拷成「新克隆」(只有 *.example)+ 另造假工作区 ws-alpha/ws-beta):
install.py --apply 自生成该机 env/roots.env;collabctl/keepalive 自动发现的确实是
ws-alpha/ws-beta、任务名现推(写死清单确已消除);部署副本本地无 roots.env 时靠
CODEBUDDY_CONFIG_DIR 兜底读到技能库那份;整套 selftest 有宿主库时 PASS 108 / FAIL 1
(唯一 FAIL=该区没有常驻心跳,环境所致;本机常驻在跑时同一套为 PASS 109 / FAIL 0)。
2026-10-11 21:49:28 +08:00

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) / "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())