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:
1 parent
c36a3f4a50
commit
c76f5be36c
7 files changed
+532
-29
No files matched your search
@@ -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**。命中 ⇒ 开接续会话。
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 进行中` ⇒ 机制自动复跑。
|
||||
⛔ 阻碍时**不关**后台任务与协作程序(用户口径第②条),只是不派活、不建检查。
|
||||
- ⛔ **别越界**:「已完成」不归结果检查 —— 那是「目标检查会话」的活(判据是完成度,不是队列)。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 行记着 `会话:<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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user