Files
admin c76f5be36c session-mechanism:接上「遇阻碍 ⇒ 置阻碍 ⇒ 机制暂停」+ 授权检查会话自解除孤儿锁
用户 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。
2026-10-09 07:24:14 +08:00

241 lines
9.9 KiB
Python
Raw Permalink 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:
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())