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

+179 -5
View File
@@ -814,9 +814,15 @@ def t_two_kinds_of_check_agent():
rows.append(("⛔ 两套 prompt **不是同一份**(实得 %s)" % (pr == pg), pr != pg))
rows.append(("结果检查:标题是「结果检查会话」(%s)", "【结果检查会话" in pr))
rows.append(("目标检查:标题是「目标检查会话」(%s)", "【目标检查会话" in pg))
# 职责边界:结果检查 ⛔ 不许有改目标状态的出口
rows.append(("结果检查 ⛔ 不含「改目标状态」的出口", "没有「改目标状态」" in pr))
rows.append(("⛔ 结果检查里不该出现 `--set-life`", "--set-life" not in pr))
# 🔴🔴 2026-10-09 **改口径**(用户令逐字:「**其他类型 阻碍 遇到阻碍修改目标状态 暂停机制 让用户决策**」):
# 旧断言是「结果检查 ⛔ 一个改状态的出口都不许有」—— 那正是空转病根的一半:
# **看得见阻碍的只有结果检查**(它靠"队列非空"触发),而有权改状态的目标检查要
# **队列为空**才触发 ⇒ 队列里躺着 `blocked` 时它永不触发 ⇒ 两条腿互相锁死。
# ✅ 现在两腿**各有一个**出口:结果检查=「阻碍」档、目标检查=「已完成」档。
# ⚠️ 两腿"允许写哪个状态"的**旗标级校验只保留一处**(`t_check_prompt_cmds_exist`)——
# 本轮实测教训:同一判据写两处 ⇒ 改一处漏一处,下面这两行就是这么报红的。
rows.append(("结果检查:写进用户口径「遇阻碍 ⇒ 暂停机制 ⇒ 让用户决策」",
"机制暂停" in pr and "让用户决策" in pr))
# 目标检查必须有改状态的出口
rows.append(("目标检查含 `--set-life 已完成` 出口", "--set-life 已完成" in pg))
rows.append(("目标检查讲了三路完成度(含 `acceptance_state`)", "acceptance_state" in pg))
@@ -964,8 +970,22 @@ def t_check_prompt_cmds_exist():
rows.append(("目标检查:`--set-life` 与值之间有空格",
"--set-life 已完成" in p))
else:
rows.append(("结果检查:⛔ 不含 `--set-life`(那是目标检查的出口)",
"--set-life" not in p))
# 🔴🔴 2026-10-09 改口径(用户令:「遇到阻碍修改目标状态 暂停机制 让用户决策」):
# 旧断言是「结果检查 ⛔ 一个 `--set-life` 都不许有」—— 那正是病根的一半:
# 结果检查是**唯一能看见 `blocked`** 的那条腿(它靠"队列非空"触发),
# 而有权改状态的「目标检查」要**队列为空**才触发 ⇒ 队列里躺着 blocked 时它永不触发
# ⇒ **看得见的没权、有权的看不见** ⇒ 只剩每 30 分钟空转一棒(实测连开 11 棒)。
# ✅ 现口径:给结果检查**一个**出口(只许「阻碍」),⛔ 「已完成」仍归目标检查。
rows.append(("结果检查:**有一个**状态出口 —— 只许 `--set-life 阻碍`",
"--set-life 阻碍" in p))
rows.append(("结果检查:⛔ 不许 `--set-life 已完成`(越权)",
"--set-life 已完成" not in p))
rows.append(("结果检查:`--set-life` 的「为什么」旗标写对(`--check-why`,⛔ 不是 `--why`)",
"--check-why" in p and "--set-life 阻碍 --by" in p))
rows.append(("结果检查:写明写完**必须回读** `goal.json` 确认",
"必须回读" in p))
rows.append(("结果检查:写明**谁把它改回「进行中」**(⛔ 不留给用户空等)",
"--set-life 进行中" in p))
return rows
@@ -7438,5 +7458,159 @@ def t_voice_core():
]
@case("🔴 孤儿锁:授权检查会话自解除 —— 但**只**放行「持有者已退出」这一档")
def t_orphan_lock():
"""🔴 2026-10-09 立(用户令逐字:「如果 工作区 项目机制 对应的会话异常 导致未解锁的情况下,
可以授权 检查会话自解除孤儿锁」)。
病根:`contentm_agent` 一把孤儿锁(持有者会话已被 terminated)把目标第 4 步挡住,
而 R9「AI 一律不得删锁、不得接管」只有行为约定、**没有任何执行面** ⇒ 检查会话连开 11 棒
(约 5 小时)每棒都撞同一面墙。⇒ 授权必须落成**一条带判据的命令**,⛔ 不靠 AI 记得去取证。
⚠️ 本用例**非有变异对照不可**:只验「terminated 能解除」的话,一个「见锁就删」的野实现
也照样全绿 —— 那正是 R9 要防的事。⇒ 必须同时验「`working` 的锁一把都不许动」。
"""
import importlib.util
import shutil as _sh
import sqlite3 as _sq
import time as _t
spec = importlib.util.spec_from_file_location("orphan_lock", HERE / "lock" / "orphan-lock.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
now_ms = int(_t.time() * 1000)
old_ms = now_ms - 40 * 60 * 1000 # 40 分钟前
unit = [
("持有者 `working` ⇒ ⛔ 不动(R9 原样)", m._judge("working", now_ms, 30.0)[0] is False),
("持有者 `terminated` ⇒ 可解除", m._judge("terminated", 0, 30.0)[0] is True),
("持有者 `error` ⇒ 可解除", m._judge("error", 0, 30.0)[0] is True),
("持有者 `archived` ⇒ 可解除", m._judge("archived", 0, 30.0)[0] is True),
("`completed` 但刚收工(0 分钟)⇒ ⛔ 不动(别掐掉随时会续聊的会话)",
m._judge("completed", now_ms, 30.0)[0] is False),
("`completed` 且已静默 40 分钟 ⇒ 可解除",
m._judge("completed", old_ms, 30.0)[0] is True),
("`completed` 但读不到活动时刻 ⇒ ⛔ 不动(fail-closed)",
m._judge("completed", None, 30.0)[0] is False),
("查不到该会话(status 空)⇒ ⛔ 不动", m._judge("", None, 30.0)[0] is False),
("状态不在已知档 ⇒ ⛔ 不动(fail-closed)", m._judge("某个没见过的档", None, 30.0)[0] is False),
]
# ── 端到端:真造锁目录 + 真造宿主库,跑真脚本 ────────────────────────
T = TEST_WS / "orphan"
_sh.rmtree(T, ignore_errors=True)
T.mkdir(parents=True, exist_ok=True)
db = T / "host.db"
con = _sq.connect(str(db))
con.execute("create table sessions (id text, status text, last_activity_at integer)")
con.execute("insert into sessions values (?,?,?)", ("deadbeef-0000-0000-0000-000000000001", "terminated", now_ms))
con.execute("insert into sessions values (?,?,?)", ("alivebee-0000-0000-0000-000000000002", "working", now_ms))
con.commit()
con.close()
def _mk(d, owner, sid):
p = T / d
p.mkdir(parents=True, exist_ok=True)
(p / "OWNER").write_text("%s\n开始:10-09 00:48\n会话:%s\nME:%s\n" % (owner, sid, owner),
encoding="utf-8")
return p
_mk("lock-dead", "执行会话-甲", "deadbeef-0000-0000-0000-000000000001")
_mk("lock-alive", "执行会话-乙", "alivebee-0000-0000-0000-000000000002")
_mk("lock-nosid", "执行会话-丙", "未取到") # 旧锁没记 sid
def _run(hostdb):
env = dict(os.environ)
env["DSH_HOST_DB"] = str(hostdb)
env["COLLABD_NO_ENSURE"] = "1"
p = subprocess.run([PY, "-u", str(HERE / "lock" / "orphan-lock.py"),
"--locks-root", str(T)],
cwd=str(HERE), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, env=env, timeout=60, errors="replace")
return p.returncode, p.stdout or ""
rc, out = _run(db)
e2e = [
("持有者 `terminated` 的锁**真被解除**", not (T / "lock-dead").exists()),
("持有者 `working` 的锁**原封不动**(R9,本用例的变异对照)", (T / "lock-alive").is_dir()),
("`OWNER` 里没记 sid 的锁**原封不动**(fail-closed)", (T / "lock-nosid").is_dir()),
("有锁未动 ⇒ 退出码 1(⛔ 不是「我全清了」的 0)", rc == 1),
("输出里写明了授权来源(可追溯)", "2026-10-09" in out),
]
# 反向对照:宿主库读不到 ⇒ **一把都不许动**
_sh.rmtree(T, ignore_errors=True)
T.mkdir(parents=True, exist_ok=True)
_mk("lock-dead", "执行会话-甲", "deadbeef-0000-0000-0000-000000000001")
rc2, out2 = _run(T / "不存在.db")
neg = [
("宿主库读不到 ⇒ 一把锁都不动(fail-closed)", (T / "lock-dead").is_dir()),
("宿主库读不到 ⇒ 退出码 1", rc2 == 1),
]
_sh.rmtree(T, ignore_errors=True)
return unit + e2e + neg
@case("🔴🔴 遇阻碍 ⇒ 置「阻碍」⇒ 机制暂停(⛔ 但「已完成」仍要建 —— 变异对照)")
def t_blocked_goal_pauses_check():
"""🔴 2026-10-09 立(用户令逐字:「**其他类型 阻碍 遇到阻碍修改目标状态 暂停机制 让用户决策**」)。
**病根**(`contentm_agent` 实测坐实):口径「遇阻碍 ⇒ 改目标状态 ⇒ 暂停机制」**只在注释里**,
代码里没接:① 闸③「`sessions-ended` 队列必须非空」对一条 `blocked` **恒真**;
② 闸① 2026-10-04 收窄时把 `sessions-ended` **整条腿**摘了出去 ⇒ 就算置了「阻碍」也照样建;
③ 结果检查 prompt 又明写「你没有改目标状态的出口」⇒ 看得见的没权、有权的看不见。
⇒ 三条腿互相锁死,只剩**每 30 分钟空转一棒**(实测连开 11 棒、约 5 小时、零派活)。
⚠️ **变异对照非有不可**:只验「阻碍 ⇒ 不建」的话,把闸① **整道**搬回来(=2026-10-04 修过的
病:目标被标「已完成」而队列里还有活 ⇒ 活永远没人接)也照样全绿。⇒ 必须同时验「已完成 ⇒ 仍建」。
"""
import inspect as _ins
m = imp()
made = []
def _fake_create(name, prompt, delay_s=90, cwd=""):
made.append(name)
return {"ok": True, "id": "selftest", "fire_at": "00:00"}
# 只桩掉"外部世界",闸本身的判据一律走真代码
m.create_check_schedule = _fake_create
m._all_sessions_idle = lambda: True
m.queue_pending = lambda: 1 # 队列非空 ⇒ 满足闸③(`sessions-ended` 的条件)
m.ws_pending_schedules = lambda exclude=None: []
m.active_schedule_exists = lambda n: False
m._check_stamp = lambda: {}
m._check_stamp_save = lambda d: None
m.log = lambda *a, **k: None
out = []
for life, want in ((m.GOAL_LIFE_BLOCK, False), (m.GOAL_LIFE_RUN, True),
(m.GOAL_LIFE_DONE, True), (m.GOAL_LIFE_WAIT, True)):
made.clear()
m.goal_life = (lambda L: (lambda: L))(life)
try:
r = m.maybe_spawn_check_agent("sessions-ended")
except Exception as e:
out.append(("目标=%s:闸不炸(实得 %s)" % (life, e.__class__.__name__), False))
continue
built = (r is not None) or bool(made)
note = "建" if built else "不建"
if life == m.GOAL_LIFE_BLOCK:
out.append(("目标=阻碍 ⇒ **停建**(机制暂停、等用户决策;实得:%s)" % note, not built))
elif life == m.GOAL_LIFE_DONE:
out.append(("目标=已完成 ⇒ **仍要建**(⛔ 别把 2026-10-04 的病搬回来;实得:%s)" % note, built))
else:
out.append(("目标=%s ⇒ 照常建(实得:%s)" % (life, note), built))
# 结构断言:闸① 必须**在闸②(会话空闲)之前**读到 `goal_life()`(⛔ 顺序颠倒=白拦)
src = _ins.getsource(m.maybe_spawn_check_agent)
p1 = src.find("goal_life()")
p2 = src.find("_all_sessions_idle()")
out.append(("闸① 排在闸② 之前(先判目标状态,再判会话是否结束)", p1 >= 0 and p2 >= 0 and p1 < p2))
out.append(("闸① 保留 `queue-empty` 那档原样(⛔ 没把 2026-10-04 的收窄改坏)",
'reason == "queue-empty"' in src and "GOAL_LIFE_RUN" in src))
return out
if __name__ == "__main__":
sys.exit(main())