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。
This commit is contained in:
admin committed 2026-10-09 07:24:14 +08:00
1 parent c36a3f4a50
commit c76f5be36c
7 files changed
+532 -29

No files matched your search

+75 -16
View File
@@ -4309,11 +4309,37 @@ CHECK_PROMPT_RESULT = (
" ⚠️ 若某条 `done` **没有 `artifact`** ⇒ 那是**台账漏了**(旧数据)⇒ 本轮把它当\n"
" **「无法核实」**处理:⛔ 别当成已完成,也 ⛔ 别自己去搜,**写明并停手**。\n"
" - `blocked`:读它的 `block_reason`;能解 ⇒ 派一条任务会话去解;\n"
" 🔴🔴 **阻碍是「域锁被占」时,先跑一次孤儿锁判定,再说「解不了」**"
"(用户 2026-10-09 授权,逐字:「如果 工作区 项目机制 对应的会话异常 导致未解锁的情况下,"
"可以授权 检查会话自解除孤儿锁」):\n"
" ① 判定(只读,⛔ 一把锁都不动):`python \"@@ORPHAN_PY@@\" --dry-run`\n"
" —— 脚本去宿主库查**持有者会话的真身状态**,⛔ 不靠你目测 `OWNER` 文件猜死活。\n"
" ② 判「持有者已退出」⇒ 这就是**孤儿锁**,**你已被授权自行解除**"
"(原 R9「AI 一律不得删锁」**不适用**于此情形)⇒ 跑 `python \"@@ORPHAN_PY@@\"` 解除,\n"
" 然后**回到本节第 2 条继续派活**,⛔ 别再停手、⛔ 别再把同一件事报一遍。\n"
" ③ 判「持有者还活着/判不出」⇒ 照旧:**停手报告**,⛔ 不动它的锁(那是 R9 的地盘)。\n"
" ⚠️ **只有这一种阻碍被授权自解**;其它 `blocked` 一律照下面这条走。\n"
" ⛔ 解不了 ⇒ **写明阻碍并停手**,⛔ 别硬派一条注定干等的活;\n"
" 🔴 「写明」的出口只有两个,缺一个=没报:\n"
" · 台账:`--report <id> --state blocked --reason \"<现状一句,含谁在等什么>\"`;\n"
" · `tmp/supervise-inbox/NEED-USER.md`:**明确喊「需用户介入」**"
"(⛔ 只写在自己域里的那份报告文档 = 用户看不到)。\n"
" - 还 `pending`/`running` 但**没有会话在跑** ⇒ 它**没被认领** ⇒ 派一条接手。\n"
"2. **派活**:能派 ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接手。\n"
"3. ⛔ **本轮你没有「改目标状态」的出口** —— 那是「目标检查会话」的活。\n"
" 即使你判断目标其实已经做完了,也**只写明「疑似已完成」并停手**,⛔ 不要自己去改 `lifecycle`。\n"
"3. 🔴🔴 **你有一个「改目标状态」的出口,但只有一个** —— 用户口径:**遇阻碍 ⇒ 把目标状态改成"
"「阻碍」⇒ 机制暂停 ⇒ 让用户决策**。\n"
" ⚠️ 为什么必须由你来做这件事:闸① 只在「阻碍」时停建检查棒,而**能看见**这条阻碍的只有你"
"(你是靠「队列非空」触发的);「目标检查会话」有权改状态、却要**队列为空**才触发 ⇒ "
"队列里躺着 `blocked` 时它**永不触发**。⇒ 不给你这个出口,两条腿会互相锁死、只剩空转。\n"
" · **触发条件(三条全中才动)**:① 那条 `blocked` 你**确认解不了**(含跑过孤儿锁判定仍判「不动」的);\n"
" ② 它是在**等人**(不是等某个会话);③ 原因已写进台账(`--state blocked --reason`)与 `NEED-USER.md`。\n"
" · **动作**:`python \"@@COLLABD_PY@@\" --set-life 阻碍 --by \"<你的会话名>\" --check-why \"<一句现状>\"`\n"
" ⚠️ 「为什么」的旗标是 **`--check-why`**,⛔ **不是 `--why`**(那是 `--retire` 的;写错会被**静默忽略**)。\n"
" ⚠️ 写完**必须回读** `goal.json` 的 `lifecycle` 确认(本项目栽过「命令不存在却 rc=0 静默放行」)。\n"
" ⇒ 之后闸① 生效、**不再建检查棒**(机制暂停),等用户处置;⛔ 你**不负责**把它改回来。\n"
" · 🔴 **恢复**:用户处置完 ⇒ **主会话**跑 `--set-life 进行中` ⇒ 机制自然复跑(这不归你管)。\n"
" · ⛔ **除这一档,你没有别的改状态出口**:判断「目标其实已经做完了」⇒ **只写明「疑似已完成」并停手**,\n"
" ⛔ 不许自己把目标改成「已完成」(那是「目标检查会话」的活 —— 你唯一能写的是「阻碍」那一档)。\n"
+ _CHECK_TAIL_A + _CHECK_TAIL_B
)
@@ -4358,6 +4384,10 @@ CHECK_PROMPT_GOAL = (
" ⚠️ 判据值是**中文写法**:`过|…`/`🔴 不过|…`/`待重验(…)` —— ⛔ 别拿「不等于 `pass`」判未过。\n"
" ⚠️ 一条**有效判据都没有**(只有 `_` 开头的说明行)⇒ **判不出来,⛔ 不许当成已完成**。\n"
"2. **没做完** ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接着做。\n"
" 🔴 **派棒前先看域锁是不是「孤儿锁」**(用户 2026-10-09 授权,检查会话可自解除):\n"
" `python \"@@ORPHAN_PY@@\" --dry-run` —— 判「持有者已退出」⇒ 跑"
" `python \"@@ORPHAN_PY@@\"` 解除,\n"
" 再派棒;判「持有者还活着」⇒ ⛔ 不动它的锁,改做不重叠的域或写 `NEED-USER.md` 喊「需用户介入」。\n"
"3. **做完了** ⇒ 先 **确认并写回验收**,再改状态(🔴 2026-10-07 用户令:「**不是拦截,是检查任务要确认这种情况**」):\n"
" · 把「二、5」那份文档里的验收结论**逐条读出来**(`过` / `🔴 不过`);\n"
" · 与 `goal.json.acceptance_state` **对一遍** —— 不一致就**以文档为准、把它改齐**:\n"
@@ -4392,6 +4422,17 @@ def _retired_check_prompt(*_a, **_k):
CHECK_PROMPT = _retired_check_prompt
# 🔴🔴 2026-10-09 加:**孤儿锁判定的脚本绝对路径**(用户当日授权检查会话自解除,见 prompt 里那段)。
# ⚠️ 走「标记 + 事后 replace」,⛔ **不往 `%s` 实参元组里再加一位** ——
# 那元组是**位置对齐**的,加错位就是 `not all arguments converted` ⇒ 被顶层记成 fatal
# ⇒ **CLI 静默无输出**(本文件已为此炸过两次,见 `_check_prompt` 那段注释)。
_ORPHAN_PY = str((HERE / "lock" / "orphan-lock.py").resolve())
_ORPHAN_MARK = "@@ORPHAN_PY@@"
# 🔴 2026-10-09:结果检查会话新增「置阻碍」出口 ⇒ prompt 里也要给出 `collabd.py` 本体路径。
# ⚠️ 同 `_ORPHAN_MARK`:走标记替换,⛔ 不动那个位置对齐的实参元组。
_COLLABD_MARK = "@@COLLABD_PY@@"
def _check_prompt(n: int, goal_title: str, reason: str = "queue-empty") -> str:
_g = str(WS) if WS else os.getcwd()
_me = os.path.abspath(__file__) if "__file__" in globals() else "collabd.py"
@@ -4425,18 +4466,20 @@ def _check_prompt(n: int, goal_title: str, reason: str = "queue-empty") -> str:
# ⚠️ 逐字对齐模板里的占位符顺序(错一位 ⇒ "not all arguments converted" ⇒
# 被顶层 handler 记成 fatal ⇒ **CLI 静默无输出**,踩过两次)。
if reason == "sessions-ended":
return CHECK_PROMPT_RESULT % ((n, goal_title, _g,
os.path.join(_g, goal_dir_rel()),
_unverifiable_block(),
_TASKS,
str(TG) if TG else os.path.join(_g, "交付物/任务图.json"),
os.path.join(_g, "tmp/supervise-inbox/goal.json"),
_me) + _tail)
return CHECK_PROMPT_GOAL % ((n, goal_title, _g, _TASKS,
return (CHECK_PROMPT_RESULT % ((n, goal_title, _g,
os.path.join(_g, goal_dir_rel()),
_unverifiable_block(),
_TASKS,
str(TG) if TG else os.path.join(_g, "交付物/任务图.json"),
os.path.join(_g, "tmp/supervise-inbox/goal.json"),
_me) + _tail)).replace(_ORPHAN_MARK, _ORPHAN_PY
).replace(_COLLABD_MARK, _me)
return (CHECK_PROMPT_GOAL % ((n, goal_title, _g, _TASKS,
str(TG) if TG else os.path.join(_g, "交付物/任务图.json"),
os.path.join(_g, "tmp/supervise-inbox/goal.json"),
os.path.join(_g, exec_doc_rel()),
_me, _me) + _tail)
_me, _me) + _tail)).replace(_ORPHAN_MARK, _ORPHAN_PY
).replace(_COLLABD_MARK, _me)
def goal_title() -> str:
@@ -4701,7 +4744,7 @@ def maybe_spawn_check_agent(reason: str) -> dict |None:
后者面对"没活了但目标可能没完";后者多一条**改生命周期**的出口。
🔴 `reason` 非法值 ⇒ **返回 None 并记日志**(⛔ 静默不动,⛔ 不猜它该走哪套)。
**五道闸**,缺一即返回 `None`(⛔ 静默不动,绝不硬闯):
**六道闸**,缺一即返回 `None`(⛔ 静默不动,绝不硬闯):
① 目标生命周期必须是「**进行中**」—— 用户第③条;默认「等待」⇒ **用户没点头就不动**
🔴🔴 **2026-10-04 收窄:只对 `queue-empty` 生效**(用户口径逐字:
「**检查程序必然要去处理队列的情况**(是等待工作区所有会话停止后,把队列情况一并处理)」)。
@@ -4709,20 +4752,36 @@ def maybe_spawn_check_agent(reason: str) -> dict |None:
日志刷屏「检查会话:目标状态=已完成(非进行中)⇒ 不建」,**队列里的活永远没人接**。
⇒ **队列非空与目标状态无关**:活没干完就是没干完,⛔ 不能因为目标被标了「已完成」就不管。
(`queue-empty` 那条仍须「进行中」—— 它的职责是**判目标该不该收口**,只在进行中才有意义。)
🔴🔴 **2026-10-09 补回「阻碍」档**(用户口径:「**遇到阻碍修改目标状态 暂停机制 让用户决策**」)。
病根(`contentm_agent` 实测坐实):上述收窄把 `sessions-ended` **整条腿**从闸① 摘了出去 ⇒
队列只剩一条 `blocked`(域锁被孤儿占)时,闸③「队列非空」**恒真** ⇒ 每 30 分钟(`CHECK_COOLDOWN_S`)
空转一棒、**连开 11 棒 5 小时零派活**;同期 `lifecycle` 一直是「进行中」—— **"阻碍 ⇒ 暂停机制"
这条口径在代码里根本没接上**(只在注释里)。⇒ 现在:`sessions-ended` **只拦「阻碍」一档**,
「已完成」**仍放行**(⛔ 别把整道闸搬回来,会把 2026-10-04 修的病重新引入)。
⚠️ **配对必需**:只加这道闸而没有「谁去置阻碍」= 白加 ⇒ 出口见 `CHECK_PROMPT_RESULT` 第 3 条
(**结果检查会话**是唯一能看见 `blocked` 的那条腿;目标检查要"队列为空"才触发、永不触发)。
② **所有会话都结束**了(含主会话 · 用户第②条)
③ **队列状态要对得上 reason**:`queue-empty` 必须队列空 + 静默 ≥20 分钟;
`sessions-ended` 必须队列**非空**(⛔ 队列也空 ⇒ 那是 `queue-empty` 那条的事,⛔ 不重复建)
④ 🔴🔴 **本工作区没有别的「待执行」排期**(用户 2026-10-03 新增判据,见 `ws_pending_schedules()`)
⑤ **同名排期未在册**(去抖)
⑥ **同类检查冷却期**(`CHECK_COOLDOWN_S`,⛔ 防堆积)
"""
if reason not in CHECK_KINDS:
log("检查会话:⛔ 未知 reason=%r(只认 %s)⇒ 不建" % (reason, "/".join(CHECK_KINDS)))
return None
life = goal_life()
# 🔴🔴 闸① 只对 `queue-empty` 生效(2026-10-04 收窄,理由见 docstring)。
# `sessions-ended` = 队列非空 = **有活没人干** ⇒ **不受目标状态限制**。
if reason == "queue-empty" and life != GOAL_LIFE_RUN:
log("检查会话:目标状态=%s(非进行中)⇒ 不建" % life)
# 🔴🔴 闸① 两档口径(2026-10-04 收窄 + **2026-10-09 补回「阻碍」档**,理由见 docstring):
# · `queue-empty`:⛔ 非「进行中」一律不建(原样,那次收窄不动它)
# · `sessions-ended`:**只拦「阻碍」一档** —— 用户口径「遇阻碍 ⇒ 改目标状态 ⇒ 机制暂停 ⇒ 让用户决策」;
# ⚠️ 「已完成」**仍放行**(2026-10-04 修的病是"被标了已完成、活却没人接")⇒ ⛔ 别把整道闸搬回来。
if reason == "queue-empty":
if life != GOAL_LIFE_RUN:
log("检查会话:目标状态=%s(非进行中)⇒ 不建" % life)
return None
elif life == GOAL_LIFE_BLOCK:
log("检查会话:目标状态=阻碍 ⇒ 不建(用户口径:阻碍 ⇒ 机制暂停、让用户决策;"
"处置完由主会话改回「进行中」即自动复跑)")
return None
if not _all_sessions_idle():
return None