@furongjun1999/dsh-memory 0.7.5 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +15 -14
  2. package/docs/eval/issue64_replay_check_/346/240/270/351/252/214/344/270/216/344/277/256/345/244/215_v1.0.md +284 -0
  3. package/docs/eval//345/217/221/345/270/20313_/344/270/212/344/270/213/346/226/207/350/207/252/347/256/241/347/220/206/346/234/272/345/210/266_v1.0.md +2 -2
  4. package/docs/eval//345/217/221/345/270/20314_/350/272/253/344/275/223/303/227/350/204/221/347/273/204/345/220/210_v1.0.md +152 -0
  5. package/docs/eval//351/262/270/345/250/230/344/270/203/347/261/273/347/212/266/346/200/201/350/277/275/350/270/252_v1.0.md +176 -0
  6. package/docs/mdcg//344/270/226/347/225/214/346/250/241/345/236/213/345/212/237/350/203/275/347/253/257_/344/275/277/347/224/250/344/270/216/350/277/220/347/273/264_v1.0.md +92 -13
  7. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +43 -43
  8. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +14 -3
  9. package/docs/mdcg//347/235/241/347/234/240/345/221/250/346/234/237_/350/277/220/347/273/264/345/211/215/346/217/220/344/270/216/347/273/264/346/212/244/346/214/207/345/215/227_v1.0.md +9 -4
  10. package/docs/plans//347/201/265/346/236/242/350/272/253/344/275/223/303/227/350/204/221_/344/270/226/347/225/214/346/250/241/345/236/213/345/257/271/346/216/245/350/256/276/350/256/241_v0.1.md +45 -2
  11. package/md_cg/chain.py +23 -7
  12. package/md_cg/consolidate.py +81 -10
  13. package/md_cg/lifecycle.py +7 -0
  14. package/md_cg/mcp_server.py +22 -0
  15. package/md_cg/mdcg.py +6 -1
  16. package/md_cg/scrub.py +4 -2
  17. package/md_cg/sleep.py +60 -10
  18. package/md_cg/test_lifecycle_retire_leak.py +90 -0
  19. package/md_cg/test_p6_consolidate.py +389 -7
  20. package/md_cg/test_sleep_gitlock.py +134 -0
  21. package/md_cg/test_spatial_coords3d.py +292 -0
  22. package/package.json +2 -2
  23. package/skills/plugin.json +1 -1
@@ -27,13 +27,20 @@
27
27
  > 两道互补(R3 只覆盖发布清单内的件,覆盖不到 hive/ 等非发布目录)。合成夹具(`X:/custom`、
28
28
  > `C:/Windows/win.ini` 等)与通用通例(Git/Docker 默认安装路径)不在判据面,避免平凡红;
29
29
  > 判据带 `--selftest`(正负样例 + 不命中自身源码)与注入探针红侧自证。
30
+ >
31
+ > **2026-10-06 更新(0.8.0 身体×脑对接 · 四条裁定之四:冒烟进发布链)**:门禁链再由 **11 条
32
+ > 扩至 12 条腿**——链尾新增 `scripts/body_e2e_smoke.py`(**身体侧视角 MCP 全链冒烟**:身体侧
33
+ > 进程逐行 JSON-RPC → 记账 `cg(op=state_event)` → 台账 → 投影 → 查询 `stg(op=state_chain)`;
34
+ > 隔离库(临时根断言)、零在役写入、退出码判据、内嵌合成样本与 `--events` 真实序列两态)。
35
+ > Linux 容器腿同步补登(python 套件清单新增守卫 + 本冒烟独立 record 行);同批新增守卫
36
+ > `md_cg/test_spatial_coords3d`(spatial 直存透传:15 断言 + 两处定点变异自证)入容器清单。
30
37
 
31
38
  ## 三层防线
32
39
 
33
40
  | 层 | 触发 | 跑什么 | 行为 |
34
41
  |---|---|---|---|
35
42
  | 本地 pre-commit | `git commit` | `python scripts/cogmap_sync.py build` + 认知图投影两道(建最小库 → `verify_discipline.py --cg-root`) | **自动重挂**行号并把两份投影文档一并暂存;投影判据体真跑,红了即拒提交(`--no-verify` 可绕) |
36
- | 发布链 | `npm publish` | `prepublishOnly`:build → test → **`npm run gate`**(十一道门禁,见下) | 任一步失败即阻断发布 |
43
+ | 发布链 | `npm publish` | `prepublishOnly`:build → test → **`npm run gate`**(十二道门禁,见下) | 任一步失败即阻断发布 |
37
44
  | CI | push / PR | `cogmap-check.yml`:`cogmap_sync.py check` + `link_check.py` | 远端兜底红灯 |
38
45
 
39
46
  ## 为什么本地钩子跑 build 而不是 check
@@ -56,7 +63,7 @@ git config core.hooksPath scripts/git-hooks
56
63
 
57
64
  ## 发布门禁清单(`npm run gate`)
58
65
 
59
- `prepublishOnly` 在 build + test 之后调用 `npm run gate`,十一道门禁与 CI **同形但不完全同覆盖**:
66
+ `prepublishOnly` 在 build + test 之后调用 `npm run gate`,十二道门禁与 CI **同形但不完全同覆盖**:
60
67
 
61
68
  | # | 命令 | 守什么 | 对应 CI |
62
69
  |---|---|---|---|
@@ -71,11 +78,15 @@ git config core.hooksPath scripts/git-hooks
71
78
  | 9 | `python scripts/gate_rust_crate_test.py` | `cargo test` @ `rust/`(Rust 侧口径漂移的回归保障) | **无**(`hive-check.yml` 只跑 `hive/Cargo.toml`,`rust/` 至今零 CI) |
72
79
  | 10 | `python scripts/gate_rust_parity.py` | `scripts/rank_parity.py` 逐位对拍(Python `search_rrf` vs Rust `mdcg-eval --serve`) | **无** |
73
80
  | 11 | `python scripts/check_local_paths.py` | **追踪面本机路径门禁**:`git ls-files` 全量公开面上不得有本机目录结构路径(npm 面由第 2 道 R3 另守;判据=机器实有前缀 8 族 + `--selftest` 正负样例 + 不命中自身源码) | `publish-artifact-check.yml`(同 workflow 内自检 + 扫描两步) |
81
+ | 12 | `python scripts/body_e2e_smoke.py` | **身体×脑组合冒烟**(0.8.0 对接):身体侧视角经 MCP 全链——记账 → 台账 → 投影 → 查询;隔离库、退出码判据、两态样本(内置/真实序列) | **无**(仅本地/发布链;Linux 容器腿同款一行) |
74
82
 
75
- > **第 11 道追加在链尾是有意的**:第 3 道的 C2 位序断言(smoke 腿紧随第 2 道)与
83
+ > **第 11 道追加在链尾是有意的(2026-10-06 起其后又有第 12 道,位序意图不变)**:第 3 道的 C2 位序断言(smoke 腿紧随第 2 道)与
76
84
  > `test_gate_rust_legs.py` 的链尾等价复跑通道(自 Rust 腿①起截尾)都不受影响;
77
85
  > 路径违规是本机秒级可判面,位序不承担反馈距离职责。
78
86
 
87
+ > **第 12 道追加在链尾同样是刻意的**:它是跨仓组合面冒烟(依赖 spawn 一个隔离 MCP 子进程,
88
+ > 秒级);C2 位序断言与 `test_gate_rust_legs.py` 链尾截尾逻辑均按「Rust 腿起点」定位,不受尾追加影响(实测 53/0 全过)。
89
+
79
90
  > **第 3 道的位序不是随意的**:它紧跟第 2 道(同为「发布件面」),且**必须在第 4 道
80
91
  > `cogmap_sync.py check` 之前**——前者的 `npm pack` 会触发 `prepare`(= 本仓的 `tsc` 构建,
81
92
  > 会重写 `lib/`),越靠近链首越能让「包面自检」与「包已定形」两件事挨着发生;塞在链尾则
@@ -288,10 +288,14 @@ python -X utf8 -m md_cg.sleep --status # 只读:含 last_cycle(
288
288
  处置 = **人工重启常驻进程**(宿主侧重启 serve / 重新拉起灵枢插件);重启后从
289
289
  心跳戳与 `<state_root>/sleep/_sleep.jsonl` 台账接着看。
290
290
 
291
- **残留锁(已知边界,如实记)**:git 子进程被 kill 后可能留下 `index.lock`
292
- ——本模块**不自动删**(可能是他进程的锁,误删更糟)。超时消息里会点名
293
- `<git_dir>/index.lock`;后续 git 操作报 lock 时,**人工核对无他进程在用后核删**。
294
- 是否加自动清理:待裁(见 issue #63 台账)。
291
+ **残留锁(有界自清,2026-10-06 落地)**:git 子进程被 kill 后可能留下 `index.lock`
292
+ ——现由 `sleep._clear_git_lock` **有界**自清(三闸口径):①只碰本模块影子仓
293
+ (单属仓,无第三方合法持锁面);②时机两处——`_git` 每次调用**前置巡检**清
294
+ **超龄锁**(mtime ≥ 300s ≫ 全调用 120s 超时上界,即崩溃残留),超时 kill 后
295
+ **force 清**当场残留(此刻仓内锁只可能来自被 kill 进程);③其余一律不动——
296
+ 超时消息如实带「已自动清理 / 未动,若后续报 lock 请人工核删」处置读数。
297
+ 清理动作记一行 `git_lock_cleared` 台账(含 age_s/force)。守卫
298
+ `python -X utf8 -m md_cg.test_sleep_gitlock`(8 断言,含「新锁不误删」反向腿)。
295
299
 
296
300
  ### 4. issue #63 的现场判据(四条,引录)
297
301
 
@@ -327,6 +331,7 @@ T3 循环续走回归 / T4 进度面 / T5 审计面)+ `--mutate` 定点变
327
331
 
328
332
  | 日期 | 要点 |
329
333
  |---|---|
334
+ | 2026-10-06 | **v1.3**(退役/锁裁定批):残留锁**有界自清**落地(`_clear_git_lock` 三闸+`_git` 两时机:前置巡检清超龄、超时 kill 后 force 清;台账 `git_lock_cleared`;守卫 `md_cg.test_sleep_gitlock` 8 断言)——原「是否自动清理待裁(issue #63 台账)」收口 |
330
335
  | 2026-10-06 | **v1.2**(issue #63):常驻循环三层无界收口——①`sleep._git` 加 `timeout=120s` + `stdin=subprocess.DEVNULL`(超时抛 `SleepGitTimeout`,与 rc≠0 失败文案分开、物化超时另支文案)②六档进/出刷既有心跳戳(`current_tick` / `last_tick_done` / `last_tick_error`)+ `stale_tick` 告警(只上报不杀线程,处置=人工重启)③同批加固 interop / audit / units / bench×3(whitebox Popen 不在加固面)——见 §十 |
331
336
  | 2026-10-06 | **v1.1**(issue #60/#61):①物化 `ok` 拆为「物化成功」+ `face_stable` 读数,**物化成功即迭代**(真源面漂移交并发闸、主库优先)②新增手动入口 `--once` / `--once --dry-run`(零落盘预览)与 `--status` 的 `last_cycle` 读数 ③台账 skip 文案区分「门拦」与「物化未完成」——见 §三.5 / §四 / §六 / §九 |
332
337
  | 2026-10-01 | v1.0 首版:版本库位置 / 维护动作(看历史·看改动·只 revert 的回退·冲突挂起)/ 开关与自动边界 / P2·P4 发版披露(§一–§八) |
@@ -2,7 +2,8 @@
2
2
 
3
3
  > 任务来源:使用者 2026-10-06「0.8.0 版本工作就是和之前的身体核心组合。让世界模型真正端到端可用。」
4
4
  > 三步流程:**测试验证成功 → 脱敏 → 全部公开**(本稿完成第一步并出读数)。
5
- > 状态:v0.1 未签收;本稿**不改一行码**(smoke 件为已验收验证工具,见 §一)。
5
+ > 状态:**v0.2——四条已裁并落地**(使用者 2026-10-06「0.8.0 对接设计四条 开始吧」,
6
+ > 按各条推荐执行);M1/M2 最小闭环已贯通,落地记录见 §七。
6
7
 
7
8
  ---
8
9
 
@@ -51,15 +52,22 @@
51
52
 
52
53
  两条路线共用同一验证工具:`python -X utf8 scripts/body_e2e_smoke.py`(隔离库、零在役写入、退出码判据)。
53
54
 
54
- ## 四、待裁清单(请逐条裁决)
55
+ ## 四、四条裁决(2026-10-06 已裁,按各条推荐执行)
55
56
 
56
57
  1. **适配器归属侧**:身体侧件(`lingshu/world/brain_store.py`,聚合仓工具面)vs 脑侧件(本仓)。
57
58
  推荐:**身体侧适配器**——章程口径"脑为包依赖、身侧件进身仓",且 `scene_model` 零改动即插。
59
+ → **已裁/已落地**:`lingshu` 仓 `fb4b98b`(含 intake 登记与双清单审计)。
58
60
  2. **3D 坐标方案**:推荐 **`spatial.coords3d` 自定义键直存米制+可选 bbox 投影**(不改既有
59
61
  `spatial.bbox` 的 2D 语义、stg 四 op 零位移);不建议扩 bbox 为 3D(破坏既有语义)。
62
+ → **已裁/已落地**:本仓 `e5d7f0c1`——`mdcg_remember` 两分支经单点 `_spatial_kw` 透传
63
+ (**缺省不落键**,既有 fm 形态逐位不变);守卫 `md_cg/test_spatial_coords3d.py`(15 断言,
64
+ 两处定点变异各自恰好命中 1 项)。
60
65
  3. **tag 过滤读(缺口①)**:脑侧给 `read/search` 加 tag 维(小活,进脑)vs 适配器侧读后过滤
61
66
  (零脑改、性能略差)。推荐:**先适配器侧**(探针验证后再决定是否进脑)。
67
+ → **已裁/已落地**:适配器侧过滤(`BrainStore.get_nodes_by_tag`:`cg(op=read)` 候选 →
68
+ 按 tags 过滤);探针实测读面召回充足(三种 query 全命中场景节点且 tags 在场)。
62
69
  4. **smoke 进门禁**:是否把 `scripts/body_e2e_smoke.py` 纳入发布链附加腿(与《秤》探针同列)。
70
+ → **已裁:进**——`npm run gate` 链尾第 12 腿 + Linux 容器腿同步补登(发布门禁链文档已更新)。
63
71
 
64
72
  ## 五、边界
65
73
 
@@ -71,6 +79,41 @@
71
79
  **测试验证 ✓(§一)** → 脱敏(readiness 扫描已 CLEAN;正式步骤=组合件入库时过双清单并登记)→
72
80
  全部公开(转公开时机属使用者操作;公开前终检清单可随时编制)。
73
81
 
82
+ ## 七、落地记录(2026-10-06 当日,四条按推荐执行)
83
+
84
+ ### 脑侧(本仓,提交 `e5d7f0c1` 已推送)
85
+
86
+ - `mdcg_remember` 两分支(gated / 非 gated add)经**单点** `_spatial_kw(a)` 透传 `spatial`:
87
+ 声明则 `frontmatter.spatial` 直存;**缺省不落键**(既有写入 fm 形态逐位不变——首版实现曾
88
+ 引入 `spatial: null` 落键,守卫 B2 当场钉出后改为条件传参);复现 meta 同键(入队后
89
+ accept 与直接落盘元数据等价);TOOLS schema 同步声明。
90
+ - 守卫 `md_cg/test_spatial_coords3d.py`:**18 断言全绿**(A–E 组,含 gated 闸门路径行为断言)
91
+ + `--mutate` 两处定点变异(M1 行为变异剥 `add` 的 spatial ⇒ 直写面与 gated 面**恰好各 1 红=2**;
92
+ M2 源码变异删一处 `_spatial_kw` 调用 ⇒ 恰好 1 红)。独立复核(子代理)全项 PASS、
93
+ 零推翻,6 条观察中两条已当场收口(本断言补强 + 沙箱扩键)。
94
+ - **smoke 进门禁**:`npm run gate` 链尾第 12 腿(`scripts/body_e2e_smoke.py`)+
95
+ Linux 容器腿同步补登;`npm run gate` **12 腿全绿**(含新腿 VERDICT=PASS 读数);
96
+ 发布门禁链文档(第 12 道)与容器套件清单(+`test_spatial_coords3d` 及其 `--mutate` 自证)同步。
97
+
98
+ ### 身侧(`lingshu` 仓,提交 `fb4b98b`/订正 `2d05e6d` 已推送)
99
+
100
+ - `lingshu/world/brain_store.py`:最小 MCP stdio 客户端 + `BrainStore`(`get_nodes_by_tag`
101
+ 适配器侧 tag 过滤 + 状态取槽位投影)+ `BrainEngine`(`add_perception` → `mdcg_remember`,
102
+ `spatial.coords3d` 直存)+ **`_ConnShim`**(legacy `ingest_scene` 的
103
+ `UPDATE nodes SET state_attributes` 直写语句 → `cg(op=state_event)` 记账翻译)+
104
+ `connect()`(连接参数零本机字面量;缺省剔除继承 `MDCG_*`、fail-closed)。
105
+ - **零改动对接实证**:`scene_model.ingest_scene` 与 `load_world_from_memory` 一行未改;
106
+ `tests/test_brain_store.py` 隔离库端到端 **10/10**(S1 握手 / S2 写入 3 实体 /
107
+ S3–S7 实体·类别·坐标·状态逐项 / S8 状态更新重载现算 / S9 tag 过滤对照)。
108
+ - intake 登记:`docs/intake/brain-store-v0.1/`(README + AUDIT:机械扫描 2 文件 0 命中
109
+ CLEAN)+ 章程引入登记表行。
110
+
111
+ ### 组合读数(发布面)
112
+
113
+ - 组合冒烟两态(内置 3 条→2 槽位;真实 25 条→11 槽位)保持 **VERDICT=PASS**(两遍复跑);
114
+ - 读面召回探针:`cg(op=read)` 对 `query=场景实体 / world_model / spatial` 三式**全命中**
115
+ 场景节点且 tags 在场——适配器侧过滤无缺口①阻塞。
116
+
74
117
  ---
75
118
 
76
119
  *v0.1 · 2026-10-06 · 编排侧编制(读码:身体侧 scene_model/semantic_anchor_graph/seven_layer_loop 等;脑侧 stg/state_slots)*
package/md_cg/chain.py CHANGED
@@ -158,8 +158,8 @@ def node_conditions(cg, nid):
158
158
  return pos
159
159
 
160
160
 
161
- # 生效条件:cg 已有 _chain_adj 且其 [0] 等于 bool(include_hierarchy)、[1] 等于可见性闸存在位时直接返回缓存的 [2];否则以 cg.index["nodes"](无 index 或无该键时视为无节点)逐节点收集 frontmatter.edges 中 edge_target 非空的出边,include_hierarchy 为真时再为 subgraph.nodes 各合成一条 relation_type="part_of"、confidence=1.0 的层级边,仅对有出边的 nid 建表,写回 cg._chain_adj=(bool(include_hierarchy), 闸存在位, adj) 后返回 adj;cg 提供 _chain_visible(nid) 谓词(读隔离,MdCGSecure 注入)时不可见 nid 的出边整体不入表、指向不可见目标的边(含层级合成边)截断——不可见节点 id、其边条件与下游拓扑对调用方不存在(walk/explain/causal_path/expand_from_seeds 全部消费者同闸);
162
- def adjacency(cg, include_hierarchy=True):
161
+ # 生效条件:cg 已有 _chain_adj 且其 [0] 等于 bool(include_hierarchy)、[1] 等于可见性闸存在位、[2] 等于 bool(skip_archived) 时直接返回缓存的 [3];否则以 cg.index["nodes"](无 index 或无该键时视为无节点)逐节点收集 frontmatter.edges 中 edge_target 非空的出边(skip_archived=False 时该轮退役过滤整体旁路——维护面全量口径),include_hierarchy 为真时再为 subgraph.nodes 各合成一条 relation_type="part_of"、confidence=1.0 的层级边,仅对有出边的 nid 建表,写回 cg._chain_adj=(bool(include_hierarchy), 闸存在位, bool(skip_archived), adj) 后返回 adj;skip_archived=True(缺省)时 lifecycle.is_archived 为真的节点出边整体不入表、指向退役目标的边(含层级合成边)截断(判据单点 lifecycle.is_archived,缺键=active fail-open;退役不删除、可显式恢复);cg 提供 _chain_visible(nid) 谓词(读隔离,MdCGSecure 注入)时不可见 nid 的出边整体不入表、指向不可见目标的边(含层级合成边)截断——不可见节点 id、其边条件与下游拓扑对调用方不存在(walk/explain/causal_path/expand_from_seeds 全部消费者同闸);
162
+ def adjacency(cg, include_hierarchy=True, skip_archived=True):
163
163
  """出邻接表:nid → [(target_id, edge_dict)]。
164
164
 
165
165
  来源两处:
@@ -172,8 +172,8 @@ def adjacency(cg, include_hierarchy=True):
172
172
  (`cg.index["nodes"]`),不是「只碰种子邻域」。故契约 S3「不走全表」这一句
173
173
  对**本函数不成立**,正确表述是「不读正文;邻接构建为 O(N) 索引条目级」。
174
174
  代价读数(`scripts/p2p4_probe.py` R7,400 条目确定性小库):冷建 ~0.24ms、
175
- 缓存命中 ~1µs;缓存键 `(include_hierarchy, 闸存在位)` 存于 `cg._chain_adj`,
176
- 写入/删除后由 `invalidate_cache` 作废。
175
+ 缓存命中 ~1µs;缓存键 `(include_hierarchy, 闸存在位, skip_archived)` 存于
176
+ `cg._chain_adj`,写入/删除后由 `invalidate_cache` 作废。
177
177
  (可选硬化路径 (b1):在索引快照的**边集合**上增量构建邻接——本轮未做,
178
178
  如需做见 P3 遗留 B 的二选一。)
179
179
 
@@ -184,17 +184,29 @@ def adjacency(cg, include_hierarchy=True):
184
184
  目标的边截断;谓词缺席(基类)行为零变化。缓存键含闸存在位;闸语义
185
185
  实例内稳定(_readable 绑定档判定恒用 principal.session,见 mdcos
186
186
  _readable),同一实例不会跨身份串台。
187
+
188
+ 退役纪律(《秤》v2.1 §5.2;2026-10-06 接线):`skip_archived=True`(缺省)时
189
+ archived 节点出边整体不入表、指向 archived 目标的边截断——`cg(op=causal,
190
+ action=chain)` 等**读/预测面**(chain()/predict.*)由此不再扩散进退役节点;
191
+ **维护面显式旁路**:`scrub`(去污染抽查,按度数排序的维护写面)传
192
+ `skip_archived=False` 取全量表——退役节点仍可被抽查(判据面 = 默认读/注入
193
+ 面必消费、维护/管理面直读,见运维文档 §九.5 面的判据)。判据单点
194
+ `lifecycle.is_archived`(缺键=active fail-open)。
187
195
  """
188
196
  cached = getattr(cg, "_chain_adj", None)
189
197
  vis = getattr(cg, "_chain_visible", None)
190
198
  vis_on = vis is not None
191
199
  if (cached is not None and cached[0] == bool(include_hierarchy)
192
- and cached[1] == vis_on):
193
- return cached[2]
200
+ and cached[1] == vis_on
201
+ and cached[2] == bool(skip_archived)):
202
+ return cached[3]
194
203
  from . import subgraph as _sg
204
+ from . import lifecycle as _lc
195
205
  nodes = ((getattr(cg, "index", None) or {}).get("nodes") or {})
196
206
  adj = {}
197
207
  for nid in list(nodes):
208
+ if skip_archived and _lc.is_archived(nodes.get(nid)):
209
+ continue # 退役节点:其出边与条件整体不入表(不解析 fm)
198
210
  if vis is not None and not vis(nid):
199
211
  continue # 不可见节点:其出边与条件整体不可见(不解析 fm)
200
212
  fm = _sg._fm(cg, nid)
@@ -202,11 +214,15 @@ def adjacency(cg, include_hierarchy=True):
202
214
  for e in (fm.get("edges") or []):
203
215
  tgt = edge_target(e)
204
216
  if tgt:
217
+ if skip_archived and _lc.is_archived(nodes.get(tgt)):
218
+ continue # 拓扑边界:指向退役目标的边径直截断
205
219
  if vis is not None and not vis(tgt):
206
220
  continue # 拓扑边界:指向不可见目标的边对调用方不存在
207
221
  out.append((tgt, e if isinstance(e, dict) else {"target": tgt}))
208
222
  if include_hierarchy:
209
223
  for ch in _sg.declared(fm)["nodes"]:
224
+ if skip_archived and _lc.is_archived(nodes.get(ch)):
225
+ continue
210
226
  if vis is not None and not vis(ch):
211
227
  continue
212
228
  out.append((ch, {"target": ch, "relation_type": "part_of",
@@ -214,7 +230,7 @@ def adjacency(cg, include_hierarchy=True):
214
230
  if out:
215
231
  adj[nid] = out
216
232
  try:
217
- cg._chain_adj = (bool(include_hierarchy), vis_on, adj)
233
+ cg._chain_adj = (bool(include_hierarchy), vis_on, bool(skip_archived), adj)
218
234
  except Exception:
219
235
  pass
220
236
  return adj
@@ -25,11 +25,17 @@
25
25
  闸门 1 · grounding 支撑度(确定性):候选短语必须能在节点正文里找到字符级依据,
26
26
  否则判为幻觉 → REJECT。(对应「不猜测」)
27
27
  闸门 2 · replay 回放(确定性):把候选条件当作查询,回放生产检索路径的判定:
28
- · pos_recall 以「生效条件」为查询 → 本节点应被召回,且不被自身负条件挡住;
28
+ · pos_recall 以「生效条件」为查询 → 本节点应被召回(issue #64:原式另带
29
+ 「负条件一票否决」因子,过严误杀共享主题词的合格负条件,
30
+ 已撤销——自否定职责由 no_conflict 承担);
29
31
  · neg_separated 以「不适用条件」为查询 → 应触发条件级负路由,且负条件与正文
30
32
  低相关(负条件必须是「域外」的,不能把知识本身否定掉);
31
33
  · no_conflict 生效条件与不适用条件不得互相覆盖。
32
34
  三者同时成立才算「条件稳定」。(对应「回放 / 断言 / 回归」)
35
+ 闸门 2 前另设**负条件两态拦截**(issue #64,见 consolidate 主流程):
36
+ 候选未产出负条件(neg_absent)/ 负条件被 grounding 删光(neg_dropped_all)
37
+ → 直接 REJECT,不进 replay、不进验证单元、不落盘——「负条件被删光」不得
38
+ 被 replay 的空集短路翻译成「通过」(否则缺「不适用条件」要素的节点仍会落盘)。
33
39
  闸门 3 · 验证单元(GLM,独立模型):逐条核验候选是否有正文依据、负条件是否真域外。
34
40
  硬约束:**验证单元只能否决,不能新增/改写**——它没有产出权,
35
41
  否则验证环节自己就成了新的幻觉源。
@@ -284,6 +290,8 @@ def http_llm(prompt: str, model: str = None, base: str = None, key: str = None,
284
290
  max_tokens=None 走三级解析(显式 > MDCG_LLM_MAX_TOKENS > DEFAULT_MAX_TOKENS);
285
291
  历史 bug(issue #24):曾硬编码 1200——思考模型 reasoning 吃光预算,
286
292
  content 空串静默落成 parse_failed,离线固化 100% DEFER。
293
+ timeout 由调用方注入(CLI --timeout / --reflect-timeout / --verify-timeout,
294
+ 默认 120 秒)——issue #64:此前 CLI 两 lambda 均未传,恒为硬默认、不可配。
287
295
  """
288
296
  if role:
289
297
  model, base, key = role_config(role, model, base, key)
@@ -496,20 +504,25 @@ def grounding_filter(cand: dict, body: str, thresholds: dict = None):
496
504
  return kept, detail
497
505
 
498
506
 
499
- # 生效条件:给定 pos_terms、neg_terms、body,返回含 pos_recall、neg_separated、no_conflict、ok 的回放判定字典。
507
+ # 生效条件:给定 pos_terms、neg_terms、body,返回含 pos_recall、neg_separated、no_conflict、ok 的回放判定字典;pos_recall 只认「生效条件在正文上有覆盖率」,不含负条件一票否决。
500
508
  def replay_check(pos_terms, neg_terms, body: str) -> dict:
501
509
  """回放生产判定:正例召回 + 负例剔除 + 无自相矛盾。
502
510
 
503
511
  复用 _path_semantic 的同一批原语,保证与生产路同源(P6 与真实路做一致性回归)。
512
+
513
+ issue #64(2026-10-06):pos_recall 原式带 `and not _neg_hit(tw_pos, neg_terms)`
514
+ 一票否决——「生效条件任一词整词出现在不适用条件里」即判负;而负条件描述的正是
515
+ **邻近易混情境**,与生效条件共享领域主题词是结构必然(不共享主题词就谈不上「邻近」),
516
+ 该因子把大量合格负条件误杀为「正例召回失败」。自否定职责已由第 3 条 no_conflict
517
+ 以覆盖率口径(<0.5 才算互相覆盖)承担——本因子与它同意图且更严,故撤销。
504
518
  """
505
519
  pos_text = " ".join(pos_terms or [])
506
520
  neg_text = " ".join(neg_terms or [])
507
521
  tw_pos = expand_query_terms_weighted(pos_text) if pos_text else {}
508
522
  tw_neg = expand_query_terms_weighted(neg_text) if neg_text else {}
509
523
 
510
- # 1. 正例:以生效条件为查询,本节点正文应被命中,且不被自身负条件挡住
511
- pos_recall = bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0 \
512
- and not _neg_hit(tw_pos, neg_terms)
524
+ # 1. 正例:以生效条件为查询,本节点正文应被命中(不含负条件一票否决,见 docstring)
525
+ pos_recall = bool(pos_text) and _weighted_coverage(tw_pos, body) > 0.0
513
526
 
514
527
  # 2. 负例:以不适用条件为查询,应触发条件级负路由;且负条件与正文低相关
515
528
  # (负条件必须是「域外」的,若与正文强相关,等于让知识否定自己)
@@ -652,7 +665,7 @@ def _apply_node(cg, e, fm: dict, content: str, kept: dict, prov: dict,
652
665
  before=before, after=evolution.state_of(cg, nid) or {})
653
666
 
654
667
 
655
- # 生效条件:给定 root,扫描正排层节点并执行反思→白箱闸门→验证→固化,返回报表 rep;require_verify=True 且无 verify_fn 时全部 DEFER。
668
+ # 生效条件:给定 root,扫描正排层节点并执行反思→白箱闸门(含负条件两态拦截)→验证→固化,返回报表 rep;require_verify=True 且无 verify_fn 时全部 DEFER。
656
669
  def consolidate(root: str, layer: str = None, limit: int = None, apply: bool = False,
657
670
  overwrite: bool = False, llm_fn=None, reflect_fn=None,
658
671
  verify_fn=None, reflect_model: str = "", verify_model: str = "",
@@ -663,6 +676,10 @@ def consolidate(root: str, layer: str = None, limit: int = None, apply: bool = F
663
676
 
664
677
  llm_fn 是 reflect_fn 的旧名(向后兼容,单模型模式)。
665
678
  require_verify=True 且无 verify_fn → 一律 DEFER(纪律 5:未经验证不固化)。
679
+ 白箱闸门内先过**负条件两态拦截**(issue #64):候选未产出负条件 → reasons
680
+ 记 neg_absent;负条件被 grounding 删光 → reasons 记 neg_dropped_all;两态
681
+ 都直接 REJECT(不进 replay / 不进验证单元 / 不落盘)——缺「不适用条件」
682
+ 要素的节点不得因空集短路被判「通过」。
666
683
  """
667
684
  reflect_fn = reflect_fn or llm_fn
668
685
  cg = MdCGOS(root)
@@ -728,6 +745,36 @@ def consolidate(root: str, layer: str = None, limit: int = None, apply: bool = F
728
745
  kept, gdetail = grounding_filter(cand, body, thresholds)
729
746
  pos = kept.get("生效条件") or []
730
747
  neg = kept.get("不适用条件") or []
748
+
749
+ # 2a) 负条件两态拦截(issue #64,2026-10-06):「不适用条件」是 CCG 必需要素,
750
+ # 候选**未产出**(neg_absent)或**产出了但被 grounding 删光**(neg_dropped_all)
751
+ # 时都必须 REJECT——不进 replay、不进验证单元、不落盘。
752
+ # 修复前把空 neg 交给 replay_check,命中其 `else: neg_separated = True` 短路,
753
+ # 「负条件被删光/未产出」被翻译成「通过」,缺要素节点照常落盘并携带
754
+ # verification_basis 声明(B 类实测:accepted=1/written=1,正文无
755
+ # 「# 不适用条件」行)。两态各自计数,便于报表区分「模型没产出」与
756
+ # 「产出被 grounding 判为幻觉丢弃」。
757
+ neg_cand = cand.get("不适用条件") or []
758
+ if not isinstance(neg_cand, list):
759
+ neg_cand = [neg_cand]
760
+ neg_cand = [t for t in neg_cand if str(t).strip()]
761
+ if not neg_cand:
762
+ rep["rejected"] += 1
763
+ _bump("neg_absent")
764
+ if verbose and len(rep["samples"]) < 8:
765
+ rep["samples"].append({"id": nid, "verdict": "REJECT",
766
+ "stage": "whitebox", "reason": "neg_absent",
767
+ "grounding": gdetail, "replay": None})
768
+ continue
769
+ if not neg:
770
+ rep["rejected"] += 1
771
+ _bump("neg_dropped_all")
772
+ if verbose and len(rep["samples"]) < 8:
773
+ rep["samples"].append({"id": nid, "verdict": "REJECT",
774
+ "stage": "whitebox", "reason": "neg_dropped_all",
775
+ "grounding": gdetail, "replay": None})
776
+ continue
777
+
731
778
  replay = replay_check(pos, neg, body)
732
779
  if not kept or not replay["ok"]:
733
780
  rep["rejected"] += 1
@@ -1477,9 +1524,21 @@ def _cli(argv=None) -> int:
1477
1524
  ap.add_argument("--min-grounding", type=float, default=None,
1478
1525
  help="统一 grounding 阈值(默认按字段 0.5 / 不适用条件 0.34)")
1479
1526
  ap.add_argument("--max-tokens", type=int, default=None,
1480
- help=f"LLM 输出预算(含思考模型 reasoning_tokens;默认 "
1527
+ help=f"LLM 输出预算共用缺省(含思考模型 reasoning_tokens;默认 "
1481
1528
  f"{DEFAULT_MAX_TOKENS},可 env {MAX_TOKENS_ENV} 覆盖;"
1482
- "子代理配置标准 v0.5 §1)")
1529
+ "子代理配置标准 v0.5 §1);两角色可分别用 "
1530
+ "--reflect-max-tokens / --verify-max-tokens 单设")
1531
+ ap.add_argument("--reflect-max-tokens", type=int, default=None,
1532
+ help="反思单元输出预算(缺省回落 --max-tokens)")
1533
+ ap.add_argument("--verify-max-tokens", type=int, default=None,
1534
+ help="验证单元输出预算(缺省回落 --max-tokens)")
1535
+ ap.add_argument("--timeout", type=float, default=120,
1536
+ help="LLM HTTP 超时秒数(缺省 120);两角色可分别用 "
1537
+ "--reflect-timeout / --verify-timeout 单设")
1538
+ ap.add_argument("--reflect-timeout", type=float, default=None,
1539
+ help="反思单元 HTTP 超时(缺省回落 --timeout)")
1540
+ ap.add_argument("--verify-timeout", type=float, default=None,
1541
+ help="验证单元 HTTP 超时(缺省回落 --timeout)")
1483
1542
  ap.add_argument("--no-llm", action="store_true",
1484
1543
  help="不调用 LLM,只做四要素完整性普查")
1485
1544
  ap.add_argument("--check", action="store_true",
@@ -1530,16 +1589,28 @@ def _cli(argv=None) -> int:
1530
1589
  f"({_ROLE_ENV[REFLECT_ROLE][2]} / DEEPSEEK_API_KEY)→ 退化为普查模式",
1531
1590
  file=sys.stderr)
1532
1591
  else:
1592
+ # 单值 --max-tokens / --timeout 继续作两角色共用缺省;角色级参数缺省
1593
+ # None → 回落共用缺省。issue #64:此前单值被同时喂 reflect/verify,
1594
+ # timeout 恒为 http_llm 默认 120 不可配(生产两 lambda 均未传)——
1595
+ # 两侧预算/超时需求可不同,须能分别单设。
1596
+ refl_mt = (a.reflect_max_tokens if a.reflect_max_tokens is not None
1597
+ else a.max_tokens)
1598
+ refl_to = (a.reflect_timeout if a.reflect_timeout is not None
1599
+ else a.timeout)
1533
1600
  reflect_fn = (lambda p: http_llm(p, role=REFLECT_ROLE, # noqa: E731
1534
1601
  model=a.reflect_model,
1535
- max_tokens=a.max_tokens))
1602
+ max_tokens=refl_mt, timeout=refl_to))
1536
1603
  if a.self_verify:
1537
1604
  verify_fn = reflect_fn
1538
1605
  elif not a.no_verify:
1539
1606
  if v_key:
1607
+ ver_mt = (a.verify_max_tokens
1608
+ if a.verify_max_tokens is not None else a.max_tokens)
1609
+ ver_to = (a.verify_timeout if a.verify_timeout is not None
1610
+ else a.timeout)
1540
1611
  verify_fn = (lambda p: http_llm(p, role=VERIFY_ROLE, # noqa: E731
1541
1612
  model=a.verify_model,
1542
- max_tokens=a.max_tokens))
1613
+ max_tokens=ver_mt, timeout=ver_to))
1543
1614
  else:
1544
1615
  print(f"[consolidate] 验证单元未配置 key"
1545
1616
  f"({_ROLE_ENV[VERIFY_ROLE][2]} / ZHIPU_API_KEY / GLM_API_KEY)"
@@ -280,6 +280,13 @@ def set_state(cg, node_id: str, dst: str, reason: str = None,
280
280
  cg._write_node(node_id, os.path.join(cg.root, node["path"]), fm,
281
281
  node.get("content") or "")
282
282
  _sync_index(cg, node_id, fm)
283
+ # 退役接线(2026-10-06):状态迁移改变 `is_archived` 读数 ⇒ 依赖该判据的
284
+ # 邻接缓存(`chain.adjacency` 的 skip_archived 过滤)必须**同点作废**——
285
+ # 此前只有本地写路径(add/_stage 尾三连)清缓存,set_state 后旧缓存会让
286
+ # 退役节点继续沿因果链/图面扩散(本批探针实证:归档后 causal_chain 仍含
287
+ # 目标)。懒导入避免模块环;`invalidate_cache` 内部自吞异常,不阻断推进。
288
+ from . import chain as _chain
289
+ _chain.invalidate_cache(cg)
283
290
  try:
284
291
  append_jsonl(os.path.join(cg.root, AUDIT_FILE), {
285
292
  "t": at, "action": "set_state", "node_id": node_id,
@@ -189,6 +189,9 @@ TOOLS = [
189
189
  consistency=_p("boolean", "写入前节点间自动冲突检测(三级决策,默认 true)"),
190
190
  on_conflict=_p("string", "冲突处置:reject(默认,抛错)|defer(不写)|record(记录放行)"),
191
191
  condition_space=_p("object", "条件空间"),
192
+ spatial=_p("object", "3D 空间锚(0.8.0 身体×脑对接约定,四条裁定之二):"
193
+ "{\"coords3d\": {x,y,z}} 自定义键直存米制;"
194
+ "可选 bbox 投影后补(不改既有 bbox 2D 语义)"),
192
195
  verification_basis=_p("string", "验证基底"),
193
196
  non_applicable_conditions=_p("array", "不适用条件")),
194
197
  },
@@ -1591,6 +1594,15 @@ def _split_ids(value):
1591
1594
  return out
1592
1595
 
1593
1596
 
1597
+ # 生效条件:a 为 MCP 参数字典时——`a["spatial"]` 非 None 返回 {"spatial": ...},否则返回 {}
1598
+ # (**缺省不传**:未声明 spatial 的写入零落键,既有 fm 形态逐位不变;0.8.0 身体×脑对接
1599
+ # 四条裁定之二引入,gated 分支与非 gated add 两处共用本单点)。
1600
+ def _spatial_kw(a):
1601
+ if a.get("spatial") is not None:
1602
+ return {"spatial": a["spatial"]}
1603
+ return {}
1604
+
1605
+
1594
1606
  # 生效条件:始终构造 ex(verify=a.get("verify")、importance=float(a["importance"]) 当 a.get("importance") is not None 否则 None、verification_basis=a.get("verification_basis") or (verdict or {}).get("basis")、non_applicable_conditions、role、derived_from=_split_ids(a.get("derived_from")) or None、relation),返回其中值不属于 (None, [], '', {}) 的键值对。
1595
1607
  def _proposal_extras(a, verdict=None):
1596
1608
  """入队时保全 write 的落盘要素,避免裁决 accept 后退化成默认值。
@@ -3446,6 +3458,11 @@ def _dispatch(cg, name, args):
3446
3458
  # A1 补接(2026-10-05):检验强度同族透传(remember_gated 内部
3447
3459
  # add(**kw) 直达;此前声明在 gated 分支静默丢弃)。
3448
3460
  check_strength=a.get("check_strength"),
3461
+ # 0.8.0 身体×脑对接(四条裁定之二,2026-10-06):`spatial`
3462
+ # 自定义键**直存**——约定 {"coords3d": {x,y,z}} 米制(可选 bbox
3463
+ # 投影后补);不改既有 fm.spatial.bbox 的 2D 语义。
3464
+ # 缺省不传(_spatial_kw)——未声明时零落键,既有 fm 形态逐位不变。
3465
+ **_spatial_kw(a),
3449
3466
  non_applicable_conditions=a.get("non_applicable_conditions"),
3450
3467
  importance_hint=hint, override=bool(a.get("override")),
3451
3468
  consistency=bool(a.get("consistency", True)),
@@ -3498,6 +3515,9 @@ def _dispatch(cg, name, args):
3498
3515
  # A1 补接(2026-10-05):检验强度入复现 meta——与
3499
3516
  # writepipe._AUTONOMY_META_KEYS 同款(两条路径元数据等价)。
3500
3517
  "check_strength",
3518
+ # 0.8.0 对接:spatial 同样随单落复现 meta(入队后
3519
+ # accept 落盘与直接落盘**元数据等价**,判据不分叉)。
3520
+ "spatial",
3501
3521
  "role", "importance") if a.get(k) is not None}
3502
3522
  # 直写面的裁决参数(本分支的 add 会自己跑冲突闸):随单落进复现 meta,
3503
3523
  # 否则「确认后落盘」与「直接落盘」两条路径的判据不等价。
@@ -3526,6 +3546,8 @@ def _dispatch(cg, name, args):
3526
3546
  verification_basis=a.get("verification_basis"),
3527
3547
  # A1 补接(2026-10-05):检验强度透传(同 B2)。
3528
3548
  check_strength=a.get("check_strength"),
3549
+ # 0.8.0 对接:spatial 直存透传(同 B2);缺省不传(零落键)。
3550
+ **_spatial_kw(a),
3529
3551
  non_applicable_conditions=a.get("non_applicable_conditions"),
3530
3552
  override=bool(a.get("override")),
3531
3553
  consistency=bool(a.get("consistency", True)),
package/md_cg/mdcg.py CHANGED
@@ -3690,7 +3690,12 @@ class MdCG:
3690
3690
  # (非法视图 ValueError——fail-closed 只针对调用方误用)
3691
3691
  and (view is None or roleviews.matches(e, view))
3692
3692
  # 时效:只在显式启用时排除已过期(not_yet 保留)
3693
- and not (validity and trust.is_expired(e, now=now))):
3693
+ and not (validity and trust.is_expired(e, now=now))
3694
+ # 退役纪律(《秤》v2.1 §5.2;2026-10-06 接线):archived
3695
+ # 不进默认检索候选——判据单点 lifecycle.is_archived
3696
+ # (缺键=active fail-open),与生产路径 `MdCGOS._candidates`
3697
+ # 同一口径(此前基类面未接,属已登记的已知未覆盖面)。
3698
+ and not lifecycle.is_archived(e)):
3694
3699
  entries.append(e)
3695
3700
 
3696
3701
  # 默认关:索引里可能残留门控字段(曾开启过 / 回填过)→ 返回前剥离,
package/md_cg/scrub.py CHANGED
@@ -115,11 +115,13 @@ def _access(cg):
115
115
  return {}, {}
116
116
 
117
117
 
118
- # 生效条件:`from . import chain` 成功且 chain.adjacency(cg) 正常返回时返回该 dict,导入或调用抛任何异常时返回 {}。
118
+ # 生效条件:`from . import chain` 成功且 chain.adjacency(cg, skip_archived=False) 正常返回时返回该 dict,导入或调用抛任何异常时返回 {}。
119
119
  def _adjacency(cg) -> dict:
120
120
  try:
121
121
  from . import chain
122
- return chain.adjacency(cg)
122
+ # 维护面全量口径(2026-10-06 退役接线批次):去污染抽查是维护写面,
123
+ # 退役节点仍可被抽查 ⇒ 显式旁路退役过滤(度数口径与接线前逐位一致)。
124
+ return chain.adjacency(cg, skip_archived=False)
123
125
  except Exception:
124
126
  return {}
125
127
 
package/md_cg/sleep.py CHANGED
@@ -78,11 +78,15 @@ DEFAULT_LOCK_TIMEOUT = 30.0
78
78
  #: 单条 git 子进程的**硬超时**(秒)——issue #63 主修(单点可调)。
79
79
  #: 取值依据:本机 git 操作为秒级(init / add / commit / rev-parse / worktree /
80
80
  #: merge / revert 全是单库本地动作),120s 为安全上界;超时即 kill
81
- #: (`subprocess.run(timeout=)` 语义),异常消息里带「若是残留锁则人工核删」的
82
- #: 指引(本模块**不自动删** index.lock——可能是他进程的锁,误删更糟)。
81
+ #: (`subprocess.run(timeout=)` 语义);残留锁处置见 `_clear_git_lock`
82
+ #: (有界自清,2026-10-06 落地原「是否自动清理」待裁项)。
83
83
  _GIT_TIMEOUT_S = 120
84
- #: git 子进程超时后可能的残留锁名(仅用于异常消息里的核删指引,不自动删)。
84
+ #: git 超时被 kill 后可能的残留锁名(有界自清的**唯一**对象名)。
85
85
  _GIT_LOCK_NAME = "index.lock"
86
+ #: 残留锁的**超龄阈值**(秒)——有界自清的第二闸:本模块全部 git 调用都带
87
+ #: `_GIT_TIMEOUT_S` 超时,合法操作结构性不可能持锁超过它;300s(≈2.5×)之上
88
+ #: 仍存在的锁只可能是崩溃/被 kill 的残留。
89
+ _GIT_LOCK_STALE_S = 300
86
90
  #: 睡眠轮次的**台账**(append-only JSONL)——落 `sleep_root()` 侧(状态面,
87
91
  #: **不是数据根**):台账是运行态,不该成为真源面的一部分、也不该进版本库。
88
92
  SLEEP_LEDGER = "_sleep.jsonl"
@@ -284,6 +288,47 @@ def face_delta(before: dict, after: dict) -> dict:
284
288
 
285
289
 
286
290
  # 生效条件:git_dir_path 与 work_tree 给定时以 ["git", "--git-dir=<git_dir_path>", "--work-tree=<work_tree>", *args] 调 subprocess.run(capture_output + text + 显式 utf-8/errors=replace + env 带 PYTHONUTF8=1 + shell=False + **timeout=_GIT_TIMEOUT_S** + **stdin=DEVNULL**)并返回 CompletedProcess;returncode 非零不抛(由调用方判);TimeoutExpired **不吞**——单点转 SleepGitTimeout(消息含「超时(_GIT_TIMEOUT_S)已被 kill」+ 命令名 + 残留锁人工核删指引)。
291
+ # 生效条件:git_dir_path 下存在 index.lock 且(force 为真 或 其 mtime 距今 ≥ _GIT_LOCK_STALE_S 秒)时删除该文件、记一行 `git_lock_cleared` 台账(写入失败不阻断)并返回 "cleared";锁不存在返回 "absent";存在但未达清理条件、或删除失败(OSError)返回 "left"(保持既有的人工核删指引面)。
292
+ def _clear_git_lock(git_dir_path: str, *, force: bool = False) -> str:
293
+ """有界自清影子仓的 git 残留锁(三闸口径,2026-10-06 落地待裁项)。
294
+
295
+ 背景(issue #63 台账「是否自动清理」):`_git` 超时 kill 自己的 git 后,
296
+ 影子仓可能留下 `index.lock`,此前只给人工核删指引。现**有界**自清:
297
+
298
+ ① **范围闸**:只碰本模块影子仓的 `<git_dir>/index.lock`——该仓由本模块
299
+ 单属、轮级独占,无第三方 git 的合法持锁面。(**调用约定面**:影子仓
300
+ 归属由调用方传入的 git_dir 保证;本函数机械面只保证对象名恒为
301
+ `<git_dir>/index.lock` 且须过②闸——独立复核 2026-10-06 观察 1 如实
302
+ 标注。)
303
+ ② **时机/年龄闸**:`force=True` 仅在本模块**刚 kill 掉自己超时的 git
304
+ 子进程**后调用(此刻仓内任何锁只可能来自该进程或其更早残留);
305
+ 非 force(每次 git 调用前的巡检)只在锁 **mtime 距今 ≥
306
+ `_GIT_LOCK_STALE_S`** 时清理(≫ 全部调用的 120s 超时上界,合法操作
307
+ 结构性不可能持锁这么久);
308
+ ③ **其余一律不删**(返回 "left"),异常消息保留人工核删指引。
309
+
310
+ 返回 "cleared" / "left" / "absent"。
311
+ """
312
+ lock = os.path.join(git_dir_path, _GIT_LOCK_NAME)
313
+ try:
314
+ st = os.stat(lock)
315
+ except OSError:
316
+ return "absent"
317
+ age = max(0.0, time.time() - st.st_mtime)
318
+ if not force and age < _GIT_LOCK_STALE_S:
319
+ return "left"
320
+ try:
321
+ os.remove(lock)
322
+ except OSError:
323
+ return "left"
324
+ try:
325
+ _append_ledger({"event": "git_lock_cleared", "lock": lock,
326
+ "age_s": round(age, 1), "force": bool(force)})
327
+ except OSError: # 台账失败不阻断 git 主链(与审计面同风格)
328
+ pass
329
+ return "cleared"
330
+
331
+
287
332
  def _git(git_dir_path: str, work_tree: str, *args: str) -> subprocess.CompletedProcess:
288
333
  """git 调用的**唯一出口**:`--git-dir` 与 `--work-tree` 一律显式给。
289
334
 
@@ -291,7 +336,7 @@ def _git(git_dir_path: str, work_tree: str, *args: str) -> subprocess.CompletedP
291
336
  两个工作树,靠这两个显式开关切换,**不依赖进程 cwd**(`hive/wm.py` 用 `-C`
292
337
  是因为它的工作树就是版本库本身;这里两者分居,故用分裂形态)。
293
338
 
294
- **有界化(issue #63)**——三件套缺一不可:
339
+ **有界化(issue #63)**——四件套缺一不可:
295
340
 
296
341
  ① `timeout=_GIT_TIMEOUT_S`:子进程**无界等待**是常驻循环停摆的直接成因
297
342
  (实测停 9.5 小时:`SustainLoop` 单线程串行六档,任一档挂住即全停)。
@@ -300,14 +345,16 @@ def _git(git_dir_path: str, work_tree: str, *args: str) -> subprocess.CompletedP
300
345
  任何读 stdin 的子进程会永久悬挂(实测:活管道下 6s 仍 poll=None;
301
346
  DEVNULL 下 0.02s 立即 EOF)。先例形态:`md_cg/run_tests.py:105`。
302
347
  ③ `TimeoutExpired` **不吞**:转 `SleepGitTimeout`(`SleepError` 子类,
303
- 既有 `except SleepError` 面照旧收得住),消息带命令名与残留锁核删
304
- 指引——`subprocess.run(timeout=)` kill 子进程后 git 可能留下
305
- `index.lock`;**本模块不自动删**(可能是他进程的锁,误删更糟),
306
- 是否自动清理列入待裁(见 issue #63 台账)。
348
+ 既有 `except SleepError` 面照旧收得住),消息带命令名与残留锁处置读数。
349
+ ④ **残留锁有界自清**(2026-10-06 落地待裁项):调用前置巡检清超龄残留
350
+ (崩溃场景),超时 kill 后 `force` 清当前残留(见 `_clear_git_lock`
351
+ 三闸口径)——消息里如实带「已清理/未清理」。
307
352
  """
308
353
  argv = ["git", "--git-dir=" + git_dir_path, "--work-tree=" + work_tree]
309
354
  argv.extend(args)
310
355
  env = dict(os.environ, PYTHONUTF8="1")
356
+ # 前置巡检:清超龄残留锁(有界自清第一时机——崩溃残留,无 handler 可依)
357
+ _clear_git_lock(git_dir_path)
311
358
  try:
312
359
  return subprocess.run(argv, capture_output=True, text=True,
313
360
  encoding="utf-8", errors="replace",
@@ -315,11 +362,14 @@ def _git(git_dir_path: str, work_tree: str, *args: str) -> subprocess.CompletedP
315
362
  timeout=_GIT_TIMEOUT_S,
316
363
  stdin=subprocess.DEVNULL)
317
364
  except subprocess.TimeoutExpired as exc:
365
+ # 第二时机:本模块刚 kill 自己的 git——此刻仓内任何锁只可能是残留
366
+ _lock_state = _clear_git_lock(git_dir_path, force=True)
318
367
  raise SleepGitTimeout(
319
368
  "git %s 超时(%ss)已被 kill(命令:git %s)——"
320
- "本次 git 操作未完成;若后续 git 操作报 lock,请人工核删 "
321
- "%s(可能是被 kill 的进程留下的残留锁,本模块不自动删)"
369
+ "本次 git 操作未完成;残留锁处置:%s(%s)"
322
370
  % (" ".join(args[:2]), _GIT_TIMEOUT_S, " ".join(args[:6]),
371
+ {"cleared": "已自动清理", "left": "未动,若后续报 lock 请人工核删",
372
+ "absent": "无残留锁"}.get(_lock_state, _lock_state),
323
373
  os.path.join(git_dir_path, _GIT_LOCK_NAME))) from exc
324
374
 
325
375