From c76f5be36c64c8a12105351bb5d0bd7a7eceb4ff Mon Sep 17 00:00:00 2001 From: maogeigei Date: Fri, 9 Oct 2026 07:24:14 +0800 Subject: [PATCH] =?UTF-8?q?session-mechanism=EF=BC=9A=E6=8E=A5=E4=B8=8A?= =?UTF-8?q?=E3=80=8C=E9=81=87=E9=98=BB=E7=A2=8D=20=E2=87=92=20=E7=BD=AE?= =?UTF-8?q?=E9=98=BB=E7=A2=8D=20=E2=87=92=20=E6=9C=BA=E5=88=B6=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E3=80=8D=EF=BC=8B=20=E6=8E=88=E6=9D=83=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E4=BC=9A=E8=AF=9D=E8=87=AA=E8=A7=A3=E9=99=A4=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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。 --- session-mechanism/SKILL.md | 2 + session-mechanism/references/manifest.md | 15 +- session-mechanism/references/rules.md | 23 ++ session-mechanism/scripts/collabd.py | 91 +++++-- .../scripts/lock/handoff-guard.sh | 6 +- session-mechanism/scripts/lock/orphan-lock.py | 240 ++++++++++++++++++ session-mechanism/scripts/selftest.py | 184 +++++++++++++- 7 files changed, 532 insertions(+), 29 deletions(-) create mode 100644 session-mechanism/scripts/lock/orphan-lock.py diff --git a/session-mechanism/SKILL.md b/session-mechanism/SKILL.md index 93b3068..93ca998 100644 --- a/session-mechanism/SKILL.md +++ b/session-mechanism/SKILL.md @@ -646,6 +646,8 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话" 2. **锁**(`scripts/lock/`): - 开工三步=`scripts/dsh.py open`(本项目入口,内部跑 ①状态 ②preflight ③抢锁); - 抢锁 `handoff-guard.sh --claim-exec "<会话名>" [--domains <域>]` —— **⛔ 机制层必须独占(不带 `--domains`)**;抢不到 ⇒ **停手 + 报告**(红线 R9:⛔ 不删锁、不接管)。 + 🔴 **例外**(用户 2026-10-09 授权):卡住的原因是**孤儿锁**(持有者会话已异常退出、不会再来释放)⇒ **检查会话**可自解除, + 用 `scripts/lock/orphan-lock.py`(先 `--dry-run`)。判据由脚本查宿主库给,⛔ 不靠自己猜;`working` 的锁**一把都不许动**。 - 释放必须反序:`--release` → `--release-exec "<会话名>"`;⛔ 不带名 ⇒ 拒释放。 3. **日志闸**:文件软 **5** / 硬 **8 MiB**;工具调用软 **200** / 硬 **250**。命中 ⇒ 开接续会话。 diff --git a/session-mechanism/references/manifest.md b/session-mechanism/references/manifest.md index 6ecc823..cf4dac3 100644 --- a/session-mechanism/references/manifest.md +++ b/session-mechanism/references/manifest.md @@ -1,13 +1,13 @@ # manifest · 包内文件清单 -> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-08 22:25 (对外叫法:机制=项目机制(用法「使用项目机制完成目标:XXXX」);角色名不变)** +> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-09 07:22 (清掉 product-planning 下 5 个外部会话遗留的 .bak(包体卫生)** > ⚠️ **2026-10-05 局部增量**:`references/pitfalls.md`(P0-77 拆条 + P0-73/P0-77 压缩)与 > `scripts/goalctl.py`(`--switch-goal` 确认闸 + 旧目标归档)两行的 md5/大小已按当天实测值更新;**其余行仍是 10-04 基线**。 > ⛔ 本表**不含** `install.log`(运行日志)与 `references/manifest.md`(自引用,写完即失真)。 > ⚠️ **provenance 列里的 `skills/multi-session-collab/…`、`skills/workbuddy-session-forensics/…` 已是历史路径** > —— 那两个目录 2026-10-01 已移到 `<工作区>/归档/技能-退役-20261001/`(⛔ 不在技能根了)。 -文件总数:**76** | 语法 / 结构检查失败:**0** +文件总数:**77** | 语法 / 结构检查失败:**0** ## ✅ 已完成 · 2026-10-05 「目标唯一性 + 换目标需确认」(用户口径落地) @@ -113,7 +113,7 @@ | 包内路径 | 字节 | md5 | 语法检查 | |---|---|---|---| -| `SKILL.md` | 115309 | `ad22e82944595982b1820e8747d76919` | — | +| `SKILL.md` | 115645 | `fbf34519e32411b5491b94e0e515f667` | — | | `assets/board-launch.py.tpl` | 4355 | `7e9dfe1c4845f20dbb89f599d80e167b` | — | | `assets/board-render-probe.js` | 14398 | `a21bd1905b34537436ce778c6bfbc288` | — | | `assets/board.html` | 151811 | `c597cd7f21543542e9cdbe486c5ccd79` | — | @@ -144,7 +144,7 @@ | `references/karpathy-output-ladder/references/ladder-workflow.md` | 2684 | `d49840707d19302d8f61f4e1d7f4ea2e` | — | | `references/karpathy-output-ladder/references/ste100.md` | 3022 | `fdbe3271c21a3c17f326d1e71de33b0d` | — | | `references/pitfalls.md` | 311254 | `d321a71e38dd246b8014b4c9100b12c7` | — | -| `references/rules.md` | 9218 | `a8fd1e616600defb724fa64af67a7348` | — | +| `references/rules.md` | 11695 | `45c197444b94773034af2509443a4cb5` | — | | `references/supervise-persistence.md` | 34799 | `6f2a0b10f248ceaad3802188f97ecb74` | — | | `references/taskgraph.md` | 3521 | `be6c6540be86475bb3430688c2987afc` | — | | `references/作业规矩/00-作业总规矩(原 agent-operating-rules).md` | 70243 | `80fc5471015b237ee7aacd6976eda6fe` | — | @@ -158,7 +158,7 @@ | `scripts/board_ext.py` | 45655 | `2319fb9d2d21bf961e03a42da9a1ad4b` | ok | | `scripts/collabctl.py` | 34007 | `d7769687e92bb878b22cad68e687fa15` | ok | | `scripts/collabd.config.example.json` | 1158 | `6655c15c411a84e3ca12fd136dbe2c60` | ok | -| `scripts/collabd.py` | 436116 | `f25b6688a50e864cb2f33b760330e333` | ok | +| `scripts/collabd.py` | 442528 | `5906584e34210b37211a414d8a801c0c` | ok | | `scripts/deliver-gateway-token.py` | 5685 | `cbba9648d404ecd2683061e19dbdb24a` | ok | | `scripts/deploy_code.py` | 5325 | `a79cf5e5ffb3e8c60a31b4d0d8845f40` | ok | | `scripts/forensics/proc-parent.py` | 2347 | `8cdfff2dbb88303298752c3777da7b20` | ok | @@ -178,12 +178,13 @@ | `scripts/init_workspace.py` | 24042 | `dda53acd24bcc66d509bbdab3ebc0488` | ok | | `scripts/judge_audit.py` | 13562 | `8f14a973fa5709a3b9a60b065dc22d7d` | ok | | `scripts/keepalive.py` | 12265 | `b84c753917e0a34cf40800bda953e7a4` | ok | -| `scripts/lock/handoff-guard.sh` | 34616 | `f38b2ec92fdc1dab9abdaca0e0bb001a` | — | +| `scripts/lock/handoff-guard.sh` | 35092 | `43f9b2616ea72c422d692653efdd6f7e` | — | | `scripts/lock/handoff-status.py` | 3923 | `3edb20f9c4021ebe2323478860bf8f6f` | ok | | `scripts/lock/op-lock.sh` | 4690 | `1a31eda3642e23693a65e1519860c744` | — | +| `scripts/lock/orphan-lock.py` | 10179 | `3519ac8e0ae7fa3f15190601475e3850` | ok | | `scripts/lock/preflight-lock.sh` | 8673 | `e985ec1cb853fae4c351cd03b171355d` | — | | `scripts/mut_run.py` | 13323 | `3bffc12e1e57d5efc743bac55e584b62` | ok | -| `scripts/selftest.py` | 493435 | `d3f0ecb225cd82062fc69fc72cdff836` | ok | +| `scripts/selftest.py` | 504129 | `6dc2fb0fc945e8c4822ac3c2029170a0` | ok | | `scripts/session-rules-check.py` | 44412 | `b2b468992d80421106316eaf451e2477` | ok | | `scripts/stop-collab.py` | 10462 | `d8043bedd23b52b6642aaf7ec8b8c980` | ok | | `scripts/supervise-launch.py` | 2503 | `f416a2f197e5524a8859c1d227ba1d05` | ok | diff --git a/session-mechanism/references/rules.md b/session-mechanism/references/rules.md index 1bcea22..cea3e48 100644 --- a/session-mechanism/references/rules.md +++ b/session-mechanism/references/rules.md @@ -29,6 +29,10 @@ ## 3 锁 - **开工先抢锁**(是"抢"不是"看");**抢不到 ⇒ 停手 + 报告**(红线 R9:⛔ 不删锁、⛔ 不接管)。 +- 🔴 **R9 的唯一例外(用户 2026-10-09 授权)**:「工作区项目机制对应的会话**异常**导致未解锁 ⇒ **检查会话**可自解除**孤儿锁**」。 + 判据**不靠猜**:`scripts/lock/orphan-lock.py` 去宿主库 `sessions` 表读持有者真身状态 —— **只有** + `terminated`/`error`/`archived`,或 `completed` 且静默 ≥30 分钟 ⇒ 可解除;`working` ⇒ **一律不动**。 + ⛔ 唯一执行面就是这支脚本(`--dry-run` 先看;⛔ 别自己写 `rm -rf` 删锁);⛔ 例外**只给检查会话**、**只覆盖这一种情形**。 - 🔴 **默认一律带域**(`--domains <本工作区域>/`);⛔ 别省略 —— 省略=**全局独占**,别人连域锁都抢不了。 - 🔴 **只有真正"全平台共用"的改动**(配置 / 加解密 / 隔离 / 锁与钩子**本身**、技能文件)才用**独占**。 - 🔴 **释放必须反序且带会话名**:`--release` → `--release-exec "<会话名>"`;⛔ 不带名 ⇒ 拒释放。 @@ -89,3 +93,22 @@ (例:「本方法只出产品视角」+「曾因照英文商业框架产出而跑偏」)。 - ⛔ **别与"事实陈述"混**:「配置里不含本工作区」「该目录不含 `board.py`」是在**描述事实**, 不在本条管辖内 —— 一刀切会把它们误删。 + +## 10 🔴 遇阻碍 ⇒ 把目标状态改成「阻碍」⇒ 机制暂停 ⇒ 让用户决策(2026-10-09 用户令) + +**口径**(用户原话:「**其他类型 阻碍 遇到阻碍修改目标状态 暂停机制 让用户决策**」): +阻碍**解不了**时,正确动作不是反复重试,而是**改目标状态把机制停住、交回用户**。 + +- **什么叫"阻碍"(三条全中)**:① 队列里那条 `blocked` **确认解不了** + (含跑过孤儿锁判定仍判「不动」的);② 它是在**等人**,不是等某个会话;③ 原因已写进台账与 `NEED-USER.md`。 +- **谁去做**:🔴 **结果检查会话** —— 它是**唯一能看见 `blocked`** 的那条腿(靠"队列非空"触发); + 「目标检查会话」虽有权改状态,却要**队列为空**才触发 ⇒ 队列里躺着 `blocked` 时它**永不触发**。 + ⇒ 不给结果检查这一个出口,两条腿会互相锁死、只剩空转(2026-10-09 `contentm_agent` 实测: + 连开 **11 棒、约 5 小时、零派活**,每 30 分钟一棒)。 +- **怎么做**:`collabd.py --set-life 阻碍 --by "<会话名>" --check-why "<一句现状>"` + ⇒ 闸① 随即生效、**不再建检查棒**(这就是"暂停机制")。 + ⚠️ 「为什么」的旗标是 **`--check-why`**,⛔ 不是 `--why`(那是 `--retire` 的,写错会被**静默忽略**)。 + ⚠️ 写完**必须回读** `goal.json` 确认(本项目栽过「命令不存在却 rc=0 静默放行」)。 +- **怎么恢复**:用户处置完 ⇒ **主会话**跑 `--set-life 进行中` ⇒ 机制自动复跑。 + ⛔ 阻碍时**不关**后台任务与协作程序(用户口径第②条),只是不派活、不建检查。 +- ⛔ **别越界**:「已完成」不归结果检查 —— 那是「目标检查会话」的活(判据是完成度,不是队列)。 diff --git a/session-mechanism/scripts/collabd.py b/session-mechanism/scripts/collabd.py index 7932e25..89ec5f0 100644 --- a/session-mechanism/scripts/collabd.py +++ b/session-mechanism/scripts/collabd.py @@ -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 --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 diff --git a/session-mechanism/scripts/lock/handoff-guard.sh b/session-mechanism/scripts/lock/handoff-guard.sh index 1578888..1baf52f 100644 --- a/session-mechanism/scripts/lock/handoff-guard.sh +++ b/session-mechanism/scripts/lock/handoff-guard.sh @@ -205,7 +205,11 @@ if [ "${1:-}" = "--claim-exec" ]; then if [ -n "$CONFLICT" ]; then rmdir "$GATELOCK" 2>/dev/null echo "✗ 抢域锁失败:**冲突域被占用** —— -$CONFLICT → **停手**:等对方释放,或**改做不重叠的域**。⛔ 不得删锁/接管(R9)" >&2 +$CONFLICT → **停手**:等对方释放,或**改做不重叠的域**。⛔ 不得删锁/接管(R9) + 🔴 **唯一例外(用户 2026-10-09 授权)**:判明是**孤儿锁**(持有者会话已**异常退出**、不会再来释放) + ⇒ **检查会话**可自解除 —— 跑 \`python <本包>/scripts/lock/orphan-lock.py\` + (先 \`--dry-run\` 看判定;⛔ 别自己写 rm -rf)。判据由该脚本查宿主库给,⛔ 不靠自己猜。 + ⚠️ 持有者仍 \`working\` ⇒ **一律不动**(R9 原样)" >&2 exit 1 fi diff --git a/session-mechanism/scripts/lock/orphan-lock.py b/session-mechanism/scripts/lock/orphan-lock.py new file mode 100644 index 0000000..1901373 --- /dev/null +++ b/session-mechanism/scripts/lock/orphan-lock.py @@ -0,0 +1,240 @@ +#!/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 行记着 `会话:`(`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()) diff --git a/session-mechanism/scripts/selftest.py b/session-mechanism/scripts/selftest.py index 8d026b7..503d810 100644 --- a/session-mechanism/scripts/selftest.py +++ b/session-mechanism/scripts/selftest.py @@ -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()) \ No newline at end of file