用户 10-09 两条令:
①「如果 工作区 项目机制 对应的会话异常 导致未解锁的情况下,可以授权 检查会话自解除孤儿锁」
②「其他类型 阻碍 遇到阻碍修改目标状态 暂停机制 让用户决策,不是有这个规则吗」
起因(contentm_agent 实测):一把域锁的持有者会话被 terminated、锁遗留未释放 ⇒ 目标第 4 步 blocked;
R9 一律禁删锁 ⇒ 检查会话连开 11 棒(约 5 小时)每棒都撞同一面墙、零派活,每 30 分钟一棒。
拆开看是三条腿互相锁死:闸③「队列非空」对一条 blocked 恒真;闸① 2026-10-04 收窄时把
sessions-ended 整条腿摘了出去(置了「阻碍」也照样建);结果检查 prompt 又明写「你没有改目标状态的出口」
⇒ 看得见的没权、有权的(目标检查要队列为空)看不见。
改动
- 新增 scripts/lock/orphan-lock.py:孤儿锁「判定 + 解除」的唯一执行面。
判据查宿主库 sessions.status(只读 SQL):working 一律不动;terminated/error/archived 可解除;
completed 须静默 >=30 分钟;查不到 sid / 库读不到一律不动(fail-closed)。
- collabd.py
· 结果检查 prompt 开「一个」状态出口:--set-life 阻碍 --by "<会话名>" --check-why "<一句现状>"
("为什么"的旗标是 --check-why,不是 --why —— 后者是 --retire 的,写错会被静默忽略);
同时把「写明阻碍」的两个出口钉死:台账 --report ... --state blocked --reason +
NEED-USER.md 里明确喊「需用户介入」。
· 闸① 改两档:queue-empty 原样(非「进行中」不建);sessions-ended「只拦『阻碍』」,
「已完成」仍放行(不把 2026-10-04 修好的病搬回来)。docstring 五道闸 -> 六道闸。
· 两条检查 prompt 都加了孤儿锁判定钩子(判「持有者已退出」才可自解除;判「活着」一律不动)。
- R9 条文加「唯一例外」:工作区 CODEBUDDY.md §3(定义处)、references/rules.md 新增 §10、SKILL.md,
以及技能包与文档库两份 handoff-guard.sh 的「抢域锁失败」提示。
- selftest.py:新增两个用例(孤儿锁 16 项,含「working 不许动」的变异对照;阻碍档 6 项,
含「已完成仍要建」的变异对照),并把写在两处的同一判据收成一处(改一处漏一处的实测教训)。
全套 PASS 108 / FAIL 0。
- references/manifest.md:按「改完包必跑」重算(77 份文件,语法失败 0)。
不在本仓(各自仓内提交):工作区 CODEBUDDY.md、文档库 CODEBUDDY.md 与 07-scripts/handoff-guard.sh。
241 lines
9.9 KiB
Python
241 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())
|