@furongjun1999/dsh-memory 0.7.5 → 0.8.1
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.
- package/README.md +43 -15
- 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
- package/docs/eval/issue65_session_recall_/344/274/232/350/257/235/351/232/224/347/246/273_/346/240/270/351/252/214/344/270/216/344/277/256/345/244/215_v1.0.md +329 -0
- 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
- 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 +160 -0
- package/docs/eval//345/217/221/345/270/20315_/344/274/232/350/257/235/351/232/224/347/246/273/344/277/256/345/244/215/344/270/216/350/272/253/344/275/223/345/272/223/345/261/225/347/244/272_v1.0.md +88 -0
- 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
- 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
- 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
- package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +14 -3
- 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
- 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
- package/md_cg/chain.py +23 -7
- package/md_cg/consolidate.py +81 -10
- package/md_cg/lifecycle.py +7 -0
- package/md_cg/mcp_server.py +22 -0
- package/md_cg/mdcg.py +6 -1
- package/md_cg/mdcos.py +24 -1
- package/md_cg/scrub.py +4 -2
- package/md_cg/sleep.py +60 -10
- package/md_cg/test_lifecycle_retire_leak.py +90 -0
- package/md_cg/test_p45_session_identity.py +250 -3
- package/md_cg/test_p6_consolidate.py +389 -7
- package/md_cg/test_sleep_gitlock.py +134 -0
- package/md_cg/test_spatial_coords3d.py +292 -0
- package/package.json +2 -2
- package/skills/plugin.json +1 -1
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> 任务来源:使用者 2026-10-06「0.8.0 版本工作就是和之前的身体核心组合。让世界模型真正端到端可用。」
|
|
4
4
|
> 三步流程:**测试验证成功 → 脱敏 → 全部公开**(本稿完成第一步并出读数)。
|
|
5
|
-
>
|
|
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]
|
|
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,
|
|
176
|
-
|
|
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
|
-
|
|
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
|
package/md_cg/consolidate.py
CHANGED
|
@@ -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
|
|
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
|
|
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=
|
|
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=
|
|
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)"
|
package/md_cg/lifecycle.py
CHANGED
|
@@ -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,
|
package/md_cg/mcp_server.py
CHANGED
|
@@ -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/mdcos.py
CHANGED
|
@@ -3231,7 +3231,7 @@ class MdCGOS(MdCG):
|
|
|
3231
3231
|
out.sort(key=lambda n: (-n["created_at"], str(n.get("id") or "")))
|
|
3232
3232
|
return out[:max(1, int(limit or 5))]
|
|
3233
3233
|
|
|
3234
|
-
# 生效条件:limit 经 max(1,min(int(limit or 5),50))、budget_tokens 经 max(200,int(budget_tokens or 1200)) 归一后逐段取数(include_state 为真才取 self_state;会话要点段、未解问题段与台账两段(goals/tasks)恒剔除 lifecycle.is_archived 条目——退役不参与默认注入;台账段取全量窗过滤后再截断 `_PACK_ROWS` 条,`tasks.active_total/done_total`
|
|
3234
|
+
# 生效条件:limit 经 max(1,min(int(limit or 5),50))、budget_tokens 经 max(200,int(budget_tokens or 1200)) 归一后逐段取数(include_state 为真才取 self_state;会话要点段、未解问题段与台账两段(goals/tasks)恒剔除 lifecycle.is_archived 条目——退役不参与默认注入;台账段取全量窗过滤后再截断 `_PACK_ROWS` 条,`tasks.active_total/done_total` 取剔除后的全窗计数;③ recent 段按会话过滤(issue #65):session 为真值且 str(session).strip() != "*" 时只保留 (r.get("meta") or {}).get("session") == session 的事件(严格相等;缺 meta.session 者丢弃——最坏空窗口),显式 "*" 不过滤(跨会话汇总),falsy 会话(None/"")退回全局窗口;会话要点段的 tags 过滤与台账段的不过滤口径不变),每段异常只把段名追加进 degraded,再由 while 循环按预算交替裁 recent/notes 尾部、任务段最后才裁并置 tasks_truncated。
|
|
3235
3235
|
def session_recall(self, session=None, limit=5, recent_limit=10,
|
|
3236
3236
|
budget_tokens=1200, include_state=True):
|
|
3237
3237
|
"""按需恢复:一次调用返回「可续接的上下文包」(替代 hook 自动注入)。
|
|
@@ -3244,6 +3244,15 @@ class MdCGOS(MdCG):
|
|
|
3244
3244
|
「还在做的」与「刚做完的」,不必先想到去查。任务属结构层、跨会话稳定,
|
|
3245
3245
|
故**不按 session 过滤**——工程台账跟着工程走,不跟着会话走。
|
|
3246
3246
|
|
|
3247
|
+
**会话口径(issue #65,2026-10-07 修)**:③ recent 段跟会话走——`session`
|
|
3248
|
+
为真值且非 `"*"` 时只回 `meta.session` 与本会话**严格相等**的事件(缺
|
|
3249
|
+
`meta.session` 者被丢弃:最坏是空窗口,绝不把别的会话的报文塞进本会话
|
|
3250
|
+
上下文——宿主把 recent 当用户输入处理,混入即静默污染,且可被压缩检查点
|
|
3251
|
+
记入);显式 `"*"` = 跨会话汇总(与 stg 面 `view_session` 的 `"*"` 同款
|
|
3252
|
+
语义);`session` 为 None/空串(falsy)时退回旧的全局窗口行为。口径分工:
|
|
3253
|
+
① 会话要点段本就按 `session:{sid}` tag 过滤;目标段与任务段是**有意跨
|
|
3254
|
+
会话**的工程面(见上段),不在此列。
|
|
3255
|
+
|
|
3247
3256
|
**退役口径(本包统一)**:本 op 是**注入面**(内容直接回给调用方/进上下文),
|
|
3248
3257
|
故四个取数段(notes / goals / tasks[active/done 两清单]/ unresolved;合计
|
|
3249
3258
|
五个清单)一律剔除
|
|
@@ -3307,6 +3316,20 @@ class MdCGOS(MdCG):
|
|
|
3307
3316
|
# ③ 近期事件(原始滚动窗口)
|
|
3308
3317
|
try:
|
|
3309
3318
|
evs = self.recent_events(limit=max(1, int(recent_limit or 10)))
|
|
3319
|
+
# issue #65(外部报告 by ducc239,对 v0.7.5 实测,本 workflow 修前复现):
|
|
3320
|
+
# 本段此前**不传会话**,而 `MdCGSecure.recent_events` 无 session 形参
|
|
3321
|
+
# (只有 private/secret 档做会话归属判断、默认档全放行)⇒ recent 恒为
|
|
3322
|
+
# 全进程窗口:多窗口/多会话并发时 A 的续接包里混入 B 的报文(宿主把
|
|
3323
|
+
# recent 当用户输入处理,可被压缩检查点记入——静默污染,不报错)。
|
|
3324
|
+
# 语义矩阵(与 `session_compact` 的 `if session:` 过滤同族对齐,那边已有正确形态):
|
|
3325
|
+
# 真值且非 "*" → 只回本会话(`meta.session` **严格相等**;缺
|
|
3326
|
+
# `meta.session` 的事件被丢弃——最坏空窗口,杜绝错块);
|
|
3327
|
+
# 显式 "*" → 不过滤(跨会话汇总合法用法,与 stg 面 `view_session`
|
|
3328
|
+
# 的 "*" 同款语义);
|
|
3329
|
+
# falsy(None/"")→ 退回旧行为(全局窗口,存量调用面零迁移)。
|
|
3330
|
+
# `strip()` 防空白包裹的 "*"(" * " 也按汇总处理)。
|
|
3331
|
+
if session and str(session).strip() != "*":
|
|
3332
|
+
evs = [r for r in evs if (r.get("meta") or {}).get("session") == session]
|
|
3310
3333
|
pack["recent"] = [{"role": r.get("role"),
|
|
3311
3334
|
"text": (r.get("text") or "")[:300],
|
|
3312
3335
|
"t": r.get("t")} for r in evs]
|
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
|
-
|
|
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
|
-
#:
|
|
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
|
-
|
|
305
|
-
`
|
|
306
|
-
|
|
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
|
|
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
|
|