@furongjun1999/dsh-memory 0.5.1 → 0.6.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.
- package/README.md +22 -2
- package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
- package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
- package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
- package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
- package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
- package/dsh/cordis-patch-profile-web.example.yml +35 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +66 -0
- package/lib/init.d.ts +88 -0
- package/lib/init.js +287 -0
- package/md_cg/evidence.py +585 -582
- package/md_cg/mcp_server.py +2 -2
- package/md_cg/mdcg.py +89 -4
- package/md_cg/mdcos.py +24 -2
- package/md_cg/review_cli.py +49 -4
- package/md_cg/test_index_crossprocess_reload.py +301 -0
- package/md_cg/test_none_id_write_guard.py +165 -0
- package/md_cg/test_review_cli_attribution.py +177 -0
- package/md_cg/test_review_cli_visibility.py +235 -0
- package/package.json +6 -1
- package/src/cli.ts +65 -0
- package/src/init.ts +347 -0
package/md_cg/mcp_server.py
CHANGED
|
@@ -2686,10 +2686,10 @@ def _export_call(cg, a):
|
|
|
2686
2686
|
|
|
2687
2687
|
# 生效条件:act=(a.get("action") or "stat").strip().lower(),apply=bool(a.get("apply")),mode=str(a.get("mode") or "").strip().lower();principal 非 None 时对 act=="rollback"、act in ("importance","separate") 且 apply、act=="longterm" 且 apply、act=="prefeed" 且 a.get("write") 真且无写权、act in ("backfill","cap","exempt") 且 apply、act in ("backfill_rollback","cap_rollback","exempt_rollback")、act=="vision_evidence" 且 apply、act=="vision_evidence_rollback"、act=="refine" 且 apply 分别 require_admin;随后 act=="longterm" 且 mode in ("list","ls","show","read") 时仅以 action/mode/limit/snapshot_id 调 cg.maintain,否则以全部参数调 cg.maintain。
|
|
2688
2688
|
def _maintain_call(cg, a):
|
|
2689
|
-
"""记忆维护(P1):importance / longterm / prefeed / separate / rollback / stat / propagate。
|
|
2689
|
+
"""记忆维护(P1):importance / longterm / prefeed / separate / rollback / stat / propagate / reload。
|
|
2690
2690
|
|
|
2691
2691
|
权限**按 action 分档**(比整 op 收窄更贴合语义):
|
|
2692
|
-
· 只读(stat / history / longterm mode=list|show
|
|
2692
|
+
· 只读(stat / history / longterm mode=list|show / reload 读面代际刷新)→ 不额外拦截;
|
|
2693
2693
|
· 写入侧闸门(prefeed write=True)→ 需 can_write;
|
|
2694
2694
|
· 批量改写(importance / separate / longterm 的 apply,以及 rollback 本身
|
|
2695
2695
|
即反向写入、无 dry-run 语义)→ require_admin。
|
package/md_cg/mdcg.py
CHANGED
|
@@ -906,10 +906,19 @@ class _DirtyDict(dict):
|
|
|
906
906
|
# 同源)。中文/逗号无路径语义,P0-1 防线(..禁令+realpath 纵深闸)不变。
|
|
907
907
|
_NODE_ID_RE = re.compile(r"^[A-Za-z0-9_.@\u4e00-\u9fff,-]{1,128}$")
|
|
908
908
|
|
|
909
|
+
# P3(2026-09-26,DSH 在役库实测 None.md 缺陷):空语义值的**字符串化污染形态**
|
|
910
|
+
# 整串禁写——批次 24 前 add 无校验,`f"{node_id}.md"` 直拼,`add(None)` 直落
|
|
911
|
+
# `None.md`(旧版直接源头);批次 24 白名单堵住 None 本体后,字符串 "None"
|
|
912
|
+
# (Python `str(None)`)/"null"(TS `String(null)`)形状合法仍放行(临时库实弹
|
|
913
|
+
# 复现:add("None") → knowledge/orphan/None.md,经 _scan_nodes 收录进检索与
|
|
914
|
+
# route 候选参与打分)。只拒**整串精确等值**(大小写敏感),不扩子串——
|
|
915
|
+
# "NoneBot_1"/"nullify_test" 等合法 id 不受影响。
|
|
916
|
+
_NODE_ID_FORBIDDEN = frozenset({"None", "null"})
|
|
917
|
+
|
|
909
918
|
|
|
910
919
|
# 生效条件:构造须传入 root,经 os.path.abspath 后以 exist_ok=True 创建该目录及 LAYERS 各层子目录;autoflush 无论取值(默认 64)都原样赋给实例。
|
|
911
920
|
class MdCG:
|
|
912
|
-
# 生效条件:root 传参即被 os.path.abspath 绝对化并 makedirs(exist_ok=True) 建立 root 与模块级 LAYERS 各层目录,autoflush(默认 64,含 0 等假值)原样存入 self.autoflush,随后 _load_index()
|
|
921
|
+
# 生效条件:root 传参即被 os.path.abspath 绝对化并 makedirs(exist_ok=True) 建立 root 与模块级 LAYERS 各层目录,autoflush(默认 64,含 0 等假值)原样存入 self.autoflush,随后 _load_index() 载入索引并以 _index_signature() 记录 _index.json 签名到 self._index_sig(跨进程读面代际感知的基线,P1b-2)、sweep_stale_temps(self.root) 清扫,并把 self 登记进模块级 _LIVE_CGS;
|
|
913
922
|
def __init__(self, root: str, autoflush: int = 64):
|
|
914
923
|
self.root = os.path.abspath(root)
|
|
915
924
|
os.makedirs(self.root, exist_ok=True)
|
|
@@ -928,6 +937,13 @@ class MdCG:
|
|
|
928
937
|
self._dirty = _DirtyDict()
|
|
929
938
|
self._log = None
|
|
930
939
|
self.index = self._load_index()
|
|
940
|
+
# P1b-2(2026-09-26,DSH 在役复验):索引只在本行装载一次,此后
|
|
941
|
+
# read/get/search 全走内存态——其它进程(review_cli autoflush=1)
|
|
942
|
+
# 写盘对常驻进程不可见(探针 dsh_restart_recheck_20260926:写后
|
|
943
|
+
# _index.json 已含探针、同进程 read 仍 null)。记录装载时的文件
|
|
944
|
+
# 签名作为基线,读路径入口经 _maybe_reload_index 按签名变化增量
|
|
945
|
+
# 重载;无变化路径只有一次 stat(微秒级)。
|
|
946
|
+
self._index_sig = self._index_signature()
|
|
931
947
|
sweep_stale_temps(self.root)
|
|
932
948
|
# 进程退出兜底登记(见模块级 _LIVE_CGS):一次性脚本漏收尾时索引仍能落盘。
|
|
933
949
|
_LIVE_CGS.add(self)
|
|
@@ -972,6 +988,62 @@ class MdCG:
|
|
|
972
988
|
idx["buckets"] = self._count_buckets(idx["nodes"])
|
|
973
989
|
return idx
|
|
974
990
|
|
|
991
|
+
# 生效条件:以 os.stat(self.index_path) 返回 (st_mtime_ns, st_size) 二元组;文件不存在或 stat 抛 OSError 时返回 None(stat 异常静默——签名探测绝不阻塞读);
|
|
992
|
+
def _index_signature(self):
|
|
993
|
+
"""_index.json 文件签名(P1b-2 跨进程读面代际的廉价哨兵)。
|
|
994
|
+
|
|
995
|
+
只 stat 一次(微秒级、无 open 无解析);(mtime_ns, size) 二元组在
|
|
996
|
+
NTFS 100ns / ext4 ns 粒度下足以识别「他进程写快照」。None = 快照
|
|
997
|
+
不存在(空库或写方尚未 compact),与他进程首次落快照的 None→非 None
|
|
998
|
+
变化同样可判。
|
|
999
|
+
"""
|
|
1000
|
+
try:
|
|
1001
|
+
st = os.stat(self.index_path)
|
|
1002
|
+
return (st.st_mtime_ns, st.st_size)
|
|
1003
|
+
except OSError:
|
|
1004
|
+
return None
|
|
1005
|
+
|
|
1006
|
+
# 生效条件:stat 对比 _index_signature() 与 self._index_sig,相等(含双侧 None)即返回 False 不做任何事;不等则调 _load_index() 重载,OSError/ValueError 时静默放弃并返回 False(重载失败不阻塞读,沿用旧内存态);成功后把 self._dirty 重放回新索引(None=tombstone pop、否则覆盖,与 _load_index 的日志重放同语义——本实例未 flush 的写入不得因重载从检索面消失)并重算 buckets,替换 self.index、刷新 self._index_sig、返回 True;
|
|
1007
|
+
def _maybe_reload_index(self):
|
|
1008
|
+
"""读路径入口的索引代际感知(P1b-2):签名变化才重载。
|
|
1009
|
+
|
|
1010
|
+
语义边界(读码定案):
|
|
1011
|
+
· flush() 只追加 _index_log 分片、不改 _index.json(见 flush 注释)
|
|
1012
|
+
→ 签名不变 → 自身写入/落账**永不**触发重载(无自重载循环);
|
|
1013
|
+
· compact_index / rebuild_index 写 _index.json 后已主动刷新签名,
|
|
1014
|
+
同理不触发;
|
|
1015
|
+
· 他进程只有写出新快照(compact/rebuild,典型在 close)才改变签名
|
|
1016
|
+
→ 此时重载并重放分片日志(_load_index 既有行为),写入可见;
|
|
1017
|
+
仅追加日志、尚无新快照的存活写方不在本探测面内(显式触发可用
|
|
1018
|
+
maintain action=reload,但日志记录只有在快照重写时才会并入);
|
|
1019
|
+
· 本实例 _dirty 未 flush 的条目在重载后**重放回内存索引**——
|
|
1020
|
+
_stage 双写 index+_dirty 的「检索看得到未落盘写入」语义保持。
|
|
1021
|
+
"""
|
|
1022
|
+
sig = self._index_signature()
|
|
1023
|
+
if sig == self._index_sig:
|
|
1024
|
+
return False
|
|
1025
|
+
try:
|
|
1026
|
+
idx = self._load_index()
|
|
1027
|
+
except (OSError, ValueError):
|
|
1028
|
+
return False # 重载失败不阻塞读:沿用旧内存态
|
|
1029
|
+
for nid, e in self._dirty.items():
|
|
1030
|
+
if e is None:
|
|
1031
|
+
idx["nodes"].pop(nid, None)
|
|
1032
|
+
else:
|
|
1033
|
+
idx["nodes"][nid] = e
|
|
1034
|
+
idx["buckets"] = self._count_buckets(idx["nodes"])
|
|
1035
|
+
self.index = idx
|
|
1036
|
+
self._index_sig = sig
|
|
1037
|
+
# 旧索引代里的派生缓存一并失效:query 结果缓存(hotcache,默认关)
|
|
1038
|
+
# 缓存的是旧候选池上的结果;解析读缓存(readcache,默认开)按 path
|
|
1039
|
+
# 缓存解析产物——他进程重写同 path 节点时旧产物陈旧。重载是稀疏
|
|
1040
|
+
# 事件(签名变化才有),此处清缓存不构成热路径开销。
|
|
1041
|
+
from . import hotcache as _hc
|
|
1042
|
+
_hc.invalidate(self)
|
|
1043
|
+
from . import readcache as _rc
|
|
1044
|
+
_rc.clear(self)
|
|
1045
|
+
return True
|
|
1046
|
+
|
|
975
1047
|
# 生效条件:遍历 LAYERS 各层目录树(os.walk,不读文件内容),对每个可达目录记录其相对 root 的正斜杠路径到 os.stat().st_mtime_ns 的映射;stat 抛 OSError 的目录跳过;返回该映射。
|
|
976
1048
|
def _dir_fingerprint(self):
|
|
977
1049
|
"""盘面目录树 mtime 指纹——检测「快照写入后盘面有增删」的廉价哨兵。
|
|
@@ -1014,7 +1086,7 @@ class MdCG:
|
|
|
1014
1086
|
buckets[b] = buckets.get(b, 0) + 1
|
|
1015
1087
|
return buckets
|
|
1016
1088
|
|
|
1017
|
-
# 生效条件:os.path.exists(self.index_path) 为真、JSON 可解析且 "schema" 等于 SCHEMA 时以该快照为基底;快照不存在用空骨架 {"schema":SCHEMA,"nodes":{},"buckets":{}}(不重扫目录);快照损坏(ValueError/OSError)或 schema 不符时降级以 self._scan_nodes() 全库扫描结果为基底(宁重扫勿清池);基底确定后对 index_log_dir 逐条重放(无 id 跳过、e 为 None 则 pop、否则覆盖),落盘并清空日志后替换 self.index
|
|
1089
|
+
# 生效条件:os.path.exists(self.index_path) 为真、JSON 可解析且 "schema" 等于 SCHEMA 时以该快照为基底;快照不存在用空骨架 {"schema":SCHEMA,"nodes":{},"buckets":{}}(不重扫目录);快照损坏(ValueError/OSError)或 schema 不符时降级以 self._scan_nodes() 全库扫描结果为基底(宁重扫勿清池);基底确定后对 index_log_dir 逐条重放(无 id 跳过、e 为 None 则 pop、否则覆盖),落盘并清空日志后替换 self.index、刷新 _index.json 签名(P1b-2 自写不自载)并返回 idx;
|
|
1018
1090
|
def compact_index(self):
|
|
1019
1091
|
with FileLock(self.index_path):
|
|
1020
1092
|
idx = {"schema": SCHEMA, "nodes": {}, "buckets": {}}
|
|
@@ -1064,6 +1136,9 @@ class MdCG:
|
|
|
1064
1136
|
atomic_write(self.index_path, json.dumps(idx, ensure_ascii=False))
|
|
1065
1137
|
ShardedLog.clear(self.index_log_dir)
|
|
1066
1138
|
self.index = idx
|
|
1139
|
+
# P1b-2:自己写了快照 → 主动刷新签名,防止后续读路径把自己的
|
|
1140
|
+
# compact 误判为「他进程写入」而做一次无谓重载(自写自载循环)。
|
|
1141
|
+
self._index_sig = self._index_signature()
|
|
1067
1142
|
return idx
|
|
1068
1143
|
|
|
1069
1144
|
# 生效条件:self._dirty 非空时才在 FileLock(index_path) 下(必要时新建 ShardedLog)逐条 append、清空 _dirty 并关闭分片句柄;self._dirty 为空时立即返回、不写任何记录;
|
|
@@ -1220,7 +1295,7 @@ class MdCG:
|
|
|
1220
1295
|
# 排序后重建序恒定(增量路径另由 cut_by_relevance 的 nid 终键兜住)。
|
|
1221
1296
|
return {k: nodes[k] for k in sorted(nodes)}
|
|
1222
1297
|
|
|
1223
|
-
# 生效条件:每次调用都以 self._scan_nodes() 的结果重建 nodes 与 buckets 并附 _dir_fingerprint(),在 FileLock 下 atomic_write 覆盖 index_path 并 ShardedLog.clear(index_log_dir),随后替换 self.index
|
|
1298
|
+
# 生效条件:每次调用都以 self._scan_nodes() 的结果重建 nodes 与 buckets 并附 _dir_fingerprint(),在 FileLock 下 atomic_write 覆盖 index_path 并 ShardedLog.clear(index_log_dir),随后替换 self.index、刷新 _index.json 签名(P1b-2 自写不自载)、清空 _dirty 并返回 idx(无 .md 时也照样覆盖为空索引);
|
|
1224
1299
|
def rebuild_index(self):
|
|
1225
1300
|
nodes = self._scan_nodes()
|
|
1226
1301
|
idx = {"schema": SCHEMA, "nodes": nodes,
|
|
@@ -1230,6 +1305,8 @@ class MdCG:
|
|
|
1230
1305
|
atomic_write(self.index_path, json.dumps(idx, ensure_ascii=False))
|
|
1231
1306
|
ShardedLog.clear(self.index_log_dir)
|
|
1232
1307
|
self.index = idx
|
|
1308
|
+
# P1b-2:rebuild 同 compact——写完快照即刷新签名(自写不自载)。
|
|
1309
|
+
self._index_sig = self._index_signature()
|
|
1233
1310
|
# broad=True:rebuild 以盘面扫描为准——ccgc/crosscheck/backfill 等
|
|
1234
1311
|
# 「直写文件 + rebuild 收尾」的写方不经 _dirty 标脏,其文件改写对
|
|
1235
1312
|
# 读缓存的可见性靠这一整池失效兜底(flush 的 clear 则不失效,见
|
|
@@ -1251,7 +1328,7 @@ class MdCG:
|
|
|
1251
1328
|
|
|
1252
1329
|
# ---------- 写 ----------
|
|
1253
1330
|
|
|
1254
|
-
# 生效条件:node_id/content 必填;node_id 不匹配 _NODE_ID_RE(^[A-Za-z0-9_.@-]{1,128}$)或含 ".."
|
|
1331
|
+
# 生效条件:node_id/content 必填;node_id 不匹配 _NODE_ID_RE(^[A-Za-z0-9_.@-]{1,128}$)或含 ".."、或整串精确等值于 _NODE_ID_FORBIDDEN("None"/"null",空语义值字符串化污染形态,P3 None.md 缺陷禁写)、或落盘 realpath 越出 self.root 时抛 ValueError(P0-1 白名单+纵深闸);layer 不在 LAYERS 内、或 verification_basis 非 None 且不在 VERIFICATION_BASIS 内时抛 ValueError;consistency 为真且 _cons.check 判 REJECT 时,on_conflict="reject" 抛 ConsistencyError、on_conflict="defer" 返回 None,verdict 为 BLINDSPOT 且 on_conflict="defer" 同样返回 None,其余情形完成写盘/入索引后返回 node_id。
|
|
1255
1332
|
def add(self, node_id: str, content: str, layer: str = "knowledge",
|
|
1256
1333
|
tags=None, condition_space=None, importance: float = 0.5,
|
|
1257
1334
|
confidence: float = 0.6, edges=None, verification_basis: str = None,
|
|
@@ -1310,6 +1387,11 @@ class MdCG:
|
|
|
1310
1387
|
raise ValueError(
|
|
1311
1388
|
f"非法 node_id:{node_id!r}(须匹配 {_NODE_ID_RE.pattern} "
|
|
1312
1389
|
f"且不含 '..'——node_id 会拼进落盘路径,穿越/绝对路径一律拒绝)")
|
|
1390
|
+
if nid_s in _NODE_ID_FORBIDDEN:
|
|
1391
|
+
raise ValueError(
|
|
1392
|
+
f"非法 node_id:{node_id!r}(空语义值的字符串化污染形态 "
|
|
1393
|
+
f"{sorted(_NODE_ID_FORBIDDEN)} 禁写——会落盘成 None.md 参与检索"
|
|
1394
|
+
f"(DSH 在役库实测缺陷 P3);fail-closed,如为合法业务 id 请改名)")
|
|
1313
1395
|
if verification_basis is not None and verification_basis not in VERIFICATION_BASIS:
|
|
1314
1396
|
raise ValueError(f"未知验证基底:{verification_basis}(允许:{VERIFICATION_BASIS})")
|
|
1315
1397
|
# 写保护:self/anchor 层、protected 标记、importance≥0.7 的**既有**节点
|
|
@@ -1969,6 +2051,7 @@ class MdCG:
|
|
|
1969
2051
|
# 生效条件:遍历 self.index["nodes"] 中 layer=="goals" 的节点,_goal_entry 取不到者跳过;status 为假值时不过滤、否则仅保留 status 相等者;按 (-priority, -created_at) 降序,limit 为真值时截断 out[:limit],否则返回全量;
|
|
1970
2052
|
def list_goals(self, status=None, limit=None):
|
|
1971
2053
|
"""列出目标,按 (priority, created_at) 降序。status 过滤 active/done/dropped。"""
|
|
2054
|
+
self._maybe_reload_index() # P1b-2:读面代际感知(goal=list/active 链)
|
|
1972
2055
|
out = []
|
|
1973
2056
|
for nid, e in self.index["nodes"].items():
|
|
1974
2057
|
if e.get("layer") != "goals":
|
|
@@ -2233,6 +2316,7 @@ class MdCG:
|
|
|
2233
2316
|
|
|
2234
2317
|
# 生效条件:index["nodes"].get(node_id) 为假值时回落 self._dirty.get(node_id),仍为假值返回 None;打开 root 下 e["path"] 抛 OSError 返回 None;_open_content 返回 None(无密钥/身份不符)返回 None;否则返回 {id, frontmatter, content, path};
|
|
2235
2318
|
def get(self, node_id: str):
|
|
2319
|
+
self._maybe_reload_index() # P1b-2:读面代际感知(他进程写快照后可见)
|
|
2236
2320
|
e = self.index["nodes"].get(node_id) or self._dirty.get(node_id)
|
|
2237
2321
|
if not e:
|
|
2238
2322
|
return None
|
|
@@ -2436,6 +2520,7 @@ class MdCG:
|
|
|
2436
2520
|
q = unify_query(q)
|
|
2437
2521
|
if not q:
|
|
2438
2522
|
return [], {"tier": None, "reason": "empty_query", "scanned": 0}
|
|
2523
|
+
self._maybe_reload_index() # P1b-2:读面代际感知(空查询早退后、读索引前)
|
|
2439
2524
|
pool_cfg = pooling.resolve(pooling.from_env(pools))
|
|
2440
2525
|
now = time.time() if validity else None # 统一取一次 now,保判定口径一致
|
|
2441
2526
|
|
package/md_cg/mdcos.py
CHANGED
|
@@ -764,6 +764,7 @@ class MdCGOS(MdCG):
|
|
|
764
764
|
q = unify_query(q)
|
|
765
765
|
if not q:
|
|
766
766
|
return [], {"tier": None, "reason": "empty_query", "scanned": 0}
|
|
767
|
+
self._maybe_reload_index() # P1b-2:读面代际感知(route/read/search 链)
|
|
767
768
|
pool_cfg = pooling.resolve(pooling.from_env(pools))
|
|
768
769
|
entries = self._candidates(layer=layer, roles=roles, include_work=include_work,
|
|
769
770
|
session=session, branch=branch, validity=validity,
|
|
@@ -1308,6 +1309,10 @@ class MdCGOS(MdCG):
|
|
|
1308
1309
|
q = unify_query(q)
|
|
1309
1310
|
if not q:
|
|
1310
1311
|
return [], {"tier": None, "reason": "empty_query", "paths": {}}
|
|
1312
|
+
# P1b-2:读面代际感知——必须在热路径 query 缓存查询**之前**:旧索引
|
|
1313
|
+
# 代里缓存的命中结果对新写入不可见(静默错答),重载成功时
|
|
1314
|
+
# _maybe_reload_index 已顺带失效 hotcache/readcache。
|
|
1315
|
+
self._maybe_reload_index()
|
|
1311
1316
|
# 热路径:query 结果缓存命中即返回(不改 RRF 核心)
|
|
1312
1317
|
# 时间算子**显式启用时绕开缓存**:缓存键不含时间参数,命中会返回
|
|
1313
1318
|
# 未按本次窗口过滤的结果(静默错答比慢更贵)。
|
|
@@ -2237,6 +2242,16 @@ class MdCGOS(MdCG):
|
|
|
2237
2242
|
if item.get("verify"):
|
|
2238
2243
|
extra["verify"] = item["verify"]
|
|
2239
2244
|
extra["verify_hash"] = expect
|
|
2245
|
+
# 归因保留(P1,2026-09-26,DSH 端在役实测:5 个裁决节点 writer 全被
|
|
2246
|
+
# designer-cli 覆盖、会话 id 各自随机):提案记录的 actor 是 propose
|
|
2247
|
+
# 时库端快照的原始写入者身份(见 propose rec 的 "actor"/"session" 字段,
|
|
2248
|
+
# 非裁决期客户端输入,不可伪造面)。accept/edit 落盘若不显式带上,
|
|
2249
|
+
# _attribution 的 setdefault 会把 writer 缺省成裁决者——原始写入者
|
|
2250
|
+
# 就此不可追溯。writer 保留原值(提案 extra 里已带 writer 的存量提案
|
|
2251
|
+
# 不覆盖,向后兼容);裁决者身份不丢失,改记 reviewer 字段。
|
|
2252
|
+
if item.get("actor"):
|
|
2253
|
+
extra.setdefault("writer", item["actor"])
|
|
2254
|
+
extra.setdefault("reviewer", self.actor)
|
|
2240
2255
|
nid = self.add(item["id"], content, layer=layer, tags=tags,
|
|
2241
2256
|
condition_space=item.get("condition_space"), **extra)
|
|
2242
2257
|
# 索引增量收尾(2026-09-16 取证):add 只把条目放进本进程内存 _dirty,
|
|
@@ -2672,7 +2687,7 @@ class MdCGOS(MdCG):
|
|
|
2672
2687
|
# 召回热路径,只在显式调用时工作。
|
|
2673
2688
|
|
|
2674
2689
|
MAINTAIN_ACTIONS = ("stat", "history", "importance", "longterm",
|
|
2675
|
-
"prefeed", "separate", "rollback",
|
|
2690
|
+
"prefeed", "separate", "rollback", "reload",
|
|
2676
2691
|
"backfill", "backfill_rollback", "backfill_history",
|
|
2677
2692
|
"cap", "cap_rollback", "cap_history",
|
|
2678
2693
|
"exempt", "exempt_rollback", "exempt_history",
|
|
@@ -2717,7 +2732,7 @@ class MdCGOS(MdCG):
|
|
|
2717
2732
|
"discard": "已丢弃(不写)", "defer": "留待复核(不写不并)"}.get(dec, "")
|
|
2718
2733
|
return out
|
|
2719
2734
|
|
|
2720
|
-
# 生效条件:act 取 str(action or "stat") 去空白并 lower 后按分支分派(importance / longterm / prefeed / separate / stat
|
|
2735
|
+
# 生效条件:act 取 str(action or "stat") 去空白并 lower 后按分支分派(reload / importance / longterm / prefeed / separate / stat 等,reload 只触发 _maybe_reload_index 并返回 {"reloaded": bool});未识别 act 走 stat 兜底;只读与写层 action 库层不鉴权,apply 类批量改写由 MCP 分发层 require_admin 把守;
|
|
2721
2736
|
def maintain(self, action="stat", layer=None, limit=None, apply=False,
|
|
2722
2737
|
min_delta=None, max_rows=None, force=False, keep=None,
|
|
2723
2738
|
mode=None, snapshot_id=None, batch=None, entry_ids=None,
|
|
@@ -2728,8 +2743,15 @@ class MdCGOS(MdCG):
|
|
|
2728
2743
|
|
|
2729
2744
|
只读 action(stat/history/longterm 预演)与写层 action(prefeed)不受
|
|
2730
2745
|
管理权限约束;apply 类批量改写由 MCP 分发层 `require_admin` 把守。
|
|
2746
|
+
|
|
2747
|
+
reload(P1b-2,DSH 建议 2):显式触发 _maybe_reload_index(签名变化
|
|
2748
|
+
才真重载),返回 {"reloaded": bool} 供外部探活常驻进程的读面代际。
|
|
2731
2749
|
"""
|
|
2732
2750
|
act = str(action or "stat").strip().lower()
|
|
2751
|
+
if act == "reload":
|
|
2752
|
+
# 探活/显式刷新:无副作用面(签名未变时是零开销 stat + False)。
|
|
2753
|
+
return {"ok": True, "op": "maintain", "action": "reload",
|
|
2754
|
+
"reloaded": bool(self._maybe_reload_index())}
|
|
2733
2755
|
if act == "importance":
|
|
2734
2756
|
return weights.recalc(self, layer=layer, limit=limit, apply=apply,
|
|
2735
2757
|
min_delta=(weights.APPLY_DELTA if min_delta is None
|
package/md_cg/review_cli.py
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
用法(root 须与待裁决部署一致:--root 或环境变量 MDCG_ROOT):
|
|
16
16
|
python -m md_cg.review_cli list
|
|
17
|
-
python -m md_cg.review_cli accept <pid> --reason "实跑测试证据"
|
|
17
|
+
python -m md_cg.review_cli accept <pid> --session <会话id> --reason "实跑测试证据"
|
|
18
18
|
python -m md_cg.review_cli reject <pid> --reason "内容有误"
|
|
19
19
|
python -m md_cg.review_cli edit <pid> --content "修正后内容" --reason "..."
|
|
20
20
|
python -m md_cg.review_cli merge <pid> --into <已有节点id> --reason "..."
|
|
@@ -27,6 +27,21 @@ noop 语义:**已评估、判定不改变任何现有记忆**——只留痕
|
|
|
27
27
|
「评估过了、无需改动」而非「否掉这条候选」,故不可借 noop 绕过 accept 门控。
|
|
28
28
|
|
|
29
29
|
裁决留痕:decisions.jsonl + 审计 md 节点(由 review_decide 内部完成)。
|
|
30
|
+
|
|
31
|
+
落盘归因(P1 修复,2026-09-26,DSH 端在役实测回告):
|
|
32
|
+
· 会话:--session(各子命令通用)缺省取环境变量 MDCG_SESSION,仍无则维持
|
|
33
|
+
现状随机会话并 stderr 告警一行。同一批裁决传同一 session,落盘节点的
|
|
34
|
+
frontmatter.session 才稳定一致(此前每次随机 sess_<uuid12>,同批 5 节点
|
|
35
|
+
5 个会话 id)。
|
|
36
|
+
· 写入者:accept/edit 落盘节点的 frontmatter.writer 保留**提案原始写入者**
|
|
37
|
+
(propose 时库端快照的 actor,如 dsh-memory);裁决者身份不丢失,记入
|
|
38
|
+
frontmatter.reviewer(designer-cli)。
|
|
39
|
+
· DSH 前置条件(环境侧自行核对,本 CLI 不校验):MCP 写入面对 DSH 形态会话
|
|
40
|
+
id(session-<8>-<4>-<4>-<4>-<12>)有 `_normalize_session` 防编造校验
|
|
41
|
+
(md_cg/mcp_server.py)——会话目录须真存在于 MDCG_DSH_SESSIONS_ROOT 或
|
|
42
|
+
~/.dsh/sessions 之下,否则该会话在 MCP 面降级为 anonymous。本 CLI 直构
|
|
43
|
+
Principal 不经该校验,但若裁决的会话 id 与 MCP 面共用,请先确认目录在位,
|
|
44
|
+
否则两侧归因对不上。
|
|
30
45
|
"""
|
|
31
46
|
import argparse
|
|
32
47
|
import json
|
|
@@ -57,12 +72,38 @@ def _root(args):
|
|
|
57
72
|
return root
|
|
58
73
|
|
|
59
74
|
|
|
60
|
-
# 生效条件:args
|
|
75
|
+
# 生效条件:args 的 session 属性为真值(含 getattr 缺省 None 回落)或环境变量 MDCG_SESSION 去空白后非空时返回该值(前者优先),两者皆无返回 None。
|
|
76
|
+
def _session_of(args):
|
|
77
|
+
"""裁决会话归属:--session > 环境变量 MDCG_SESSION > None(随机 + 告警)。"""
|
|
78
|
+
s = str(getattr(args, "session", None)
|
|
79
|
+
or os.environ.get("MDCG_SESSION", "") or "").strip()
|
|
80
|
+
return s or None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# 生效条件:args 就绪时先经 _session_of 取裁决会话归属,取到 None 时向 stderr 告警一行(缺省随机会话、同批裁决请传同一 --session)后维持现状;随后构造写死权限的 Principal(actor="designer-cli", clearance="secret", can_write=True, can_admin=True, role="designer", auth_mode="local-cli", session=<上述归属>),再以 _root(args) 取到的存储根返回 MdCGSecure(root, principal=p, autoflush=1)。
|
|
61
84
|
def _cg(args):
|
|
85
|
+
session = _session_of(args)
|
|
86
|
+
if session is None:
|
|
87
|
+
# P1 归因(2026-09-26,DSH 端在役实测):缺省随机会话会让同一批裁决
|
|
88
|
+
# 得到互不相同的落盘归属(5 节点 5 个 sess_* 实录)。不拒绝、告警一行
|
|
89
|
+
# 后维持现状(向后兼容);要稳定归属请显式传同一 --session。
|
|
90
|
+
sys.stderr.write("警告:未指定 --session / 环境变量 MDCG_SESSION,"
|
|
91
|
+
"本次裁决落盘使用随机会话 id——同一批裁决请传同一 "
|
|
92
|
+
"--session 以稳定归属。\n")
|
|
62
93
|
p = Principal(actor="designer-cli", clearance="secret",
|
|
63
94
|
can_write=True, can_admin=True, role="designer",
|
|
64
|
-
auth_mode="local-cli")
|
|
65
|
-
|
|
95
|
+
auth_mode="local-cli", session=session)
|
|
96
|
+
# 索引可见性(P1b,2026-09-26):autoflush=1 —— 与 mcp_server.py:3695-3702
|
|
97
|
+
# 同款(server 级兜底 2026-09-16 已修),本处是同一兜底的**第二落点**,
|
|
98
|
+
# DSH 端在役实测 read null 后补齐。根因:缺省 autoflush=64 时写入先经
|
|
99
|
+
# _stage 入内存 _dirty,攒够阈值才 flush() 落分片日志 `_index_log/`;
|
|
100
|
+
# 本 CLI 正常退出有 main 的 finally cg.close() 与库层 atexit 双兜底
|
|
101
|
+
# (mdcg.py _LIVE_CGS),但兜底刻意只覆盖正常退出——被 kill / 崩溃时
|
|
102
|
+
# 两者都不执行,裁决节点停在内存索引、日志从未落,盘面文件在而索引
|
|
103
|
+
# 链路断,在役旧读面 `_load_index` 盲信既有快照即 read null。逐条落
|
|
104
|
+
# 日志后任意退出形态下写入对其它进程立即可见(review_decide 自身的
|
|
105
|
+
# 显式 flush 只覆盖其目标/审计节点,且与节点文件落盘之间仍有窗口)。
|
|
106
|
+
return MdCGSecure(_root(args), principal=p, autoflush=1)
|
|
66
107
|
|
|
67
108
|
|
|
68
109
|
# 生效条件:rec 支持 .get 时,取 rec.get("content") 为假值则取 rec.get("statement")、再为假值则取空串,把其中的换行替换为空格,并按 width(缺省 66)切片后仅当 len(text) > width 才追加 "…",返回该字符串。
|
|
@@ -131,6 +172,10 @@ def main(argv=None):
|
|
|
131
172
|
description="灵枢审核队列裁决(designer 权限)")
|
|
132
173
|
common = argparse.ArgumentParser(add_help=False)
|
|
133
174
|
common.add_argument("--root", help="存储根目录(默认环境变量 MDCG_ROOT)")
|
|
175
|
+
common.add_argument("--session", default=None,
|
|
176
|
+
help="裁决会话 id(落盘归属 frontmatter.session;缺省取"
|
|
177
|
+
"环境变量 MDCG_SESSION,仍无则随机会话并 stderr 告警。"
|
|
178
|
+
"同一批裁决传同一值即得同一归属)")
|
|
134
179
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
135
180
|
|
|
136
181
|
sub.add_parser("list", help="列出待审条目", parents=[common])
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · 常驻进程索引跨进程重载守卫(P1b-2 读面缓存代际,DSH 端在役复验)
|
|
3
|
+
|
|
4
|
+
缺陷(修复前):`MdCG.__init__` 仅启动时 `self.index = self._load_index()`
|
|
5
|
+
一次(mdcg.py),此后 read/get/search 全走内存态——其它进程(review_cli
|
|
6
|
+
autoflush=1)写盘对常驻进程不可见。复验证据链(dsh_restart_recheck_20260926,
|
|
7
|
+
时间戳锁死):22:13:16 常驻进程装载快照(14135 节点) → 22:14:37 review_cli
|
|
8
|
+
写入 → 22:14:43 磁盘 _index.json 已含探针 → 同常驻进程 read 仍 null、旁路
|
|
9
|
+
新进程可读。
|
|
10
|
+
|
|
11
|
+
修复:__init__ 记录 _index.json 签名 (st_mtime_ns, st_size);读路径入口
|
|
12
|
+
(get / search(基类与 MdCGOS)/ search_rrf(热缓存查询之前)/ list_goals)
|
|
13
|
+
单点调 _maybe_reload_index()——签名变化才重载(一次 stat 微秒级);自身
|
|
14
|
+
flush 只追加 _index_log 分片不改 _index.json(签名不变正合适)、compact/
|
|
15
|
+
rebuild 写快照后主动刷签名(自写不自载);maintain action=reload 显式探活。
|
|
16
|
+
|
|
17
|
+
守卫(六组,全部临时库 + 哑主密钥,绝不触真实 ~/.mdcg):
|
|
18
|
+
① 红转绿(同解释器双实例,复刻 DSH 形态):A 建库常驻 → B 独立实例
|
|
19
|
+
add+close(compact 落快照=review_cli 退出同款)→ 盘面快照已含探针 →
|
|
20
|
+
A 禁用 reload 时 read null(修前病灶形态)→ 恢复后 read/search 命中
|
|
21
|
+
(修复生效)→ C 实例 forget+close → A read 归 null(tombstone 重放)。
|
|
22
|
+
② 旁路新进程可读(subprocess 全新解释器 _load_index+read)——修前已绿,
|
|
23
|
+
钉住「盘面账本正确」,防两层兜底(批次39 指纹自愈)未来被移除时复发。
|
|
24
|
+
③ 自身写入零重载:A' 写入+flush+compact+rebuild 全程真实重载次数 0
|
|
25
|
+
(自写不自载循环防线);连续读 N 次=签名探测 N 次、重载 0 次。
|
|
26
|
+
④ maintain reload 动作在位:MAINTAIN_ACTIONS 登记 + {"reloaded": bool}
|
|
27
|
+
返回(他进程写入后 True 且读面可见;幂等二调 False)。
|
|
28
|
+
⑤ goal=list 链路跨进程可见(list_goals 接线腿,active_goals 同源)。
|
|
29
|
+
⑥ 性能粗测:无跨进程写入时接线态 vs 重载桩空转态逐读耗时对照(宽松
|
|
30
|
+
上界断言防「重载被改成无条件」的性能回归;绝对数字只打印不作硬门)。
|
|
31
|
+
|
|
32
|
+
如实边界:② 在修复前的 HEAD 上已绿(旁路进程本就走全新 _load_index);
|
|
33
|
+
修复前红的正是 ① 的常驻读面——本守卫以「禁用 reload → null」复现该形态,
|
|
34
|
+
并以 ③ 的计数桩钉住接线与自写不自载两道防线。
|
|
35
|
+
|
|
36
|
+
运行:python -m md_cg.test_index_crossprocess_reload
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import shutil
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
import tempfile
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
from .mdcos import MdCGSecure
|
|
49
|
+
from .security import Principal
|
|
50
|
+
|
|
51
|
+
PASS = FAIL = 0
|
|
52
|
+
FAILS = []
|
|
53
|
+
|
|
54
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
55
|
+
|
|
56
|
+
DUMMY_KEY = "ab" * 32 # 哑主密钥:本进程与子进程绝不触真实 ~/.mdcg
|
|
57
|
+
|
|
58
|
+
READ_CHILD = r'''
|
|
59
|
+
import sys
|
|
60
|
+
sys.path.insert(0, sys.argv[2])
|
|
61
|
+
from md_cg.mdcos import MdCGSecure
|
|
62
|
+
from md_cg.security import Principal
|
|
63
|
+
p = Principal(actor="p1b2-reader", clearance="internal", can_write=True,
|
|
64
|
+
can_admin=True, role="designer", auth_mode="test")
|
|
65
|
+
cg = MdCGSecure(sys.argv[1], principal=p)
|
|
66
|
+
try:
|
|
67
|
+
print("READ " + " ".join("1" if cg.get(nid) is not None else "0"
|
|
68
|
+
for nid in sys.argv[3].split(",")))
|
|
69
|
+
finally:
|
|
70
|
+
cg.close()
|
|
71
|
+
'''
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check(name, cond, detail=""):
|
|
75
|
+
global PASS, FAIL
|
|
76
|
+
if cond:
|
|
77
|
+
PASS += 1
|
|
78
|
+
print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
|
|
79
|
+
else:
|
|
80
|
+
FAIL += 1
|
|
81
|
+
FAILS.append(name)
|
|
82
|
+
print(f" [FAIL] {name} · {detail}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _principal(**kw):
|
|
86
|
+
d = dict(actor="p1b2", clearance="internal", can_write=True,
|
|
87
|
+
can_admin=True, role="designer", auth_mode="test")
|
|
88
|
+
d.update(kw)
|
|
89
|
+
return Principal(**d)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _spawn_read(root, ids):
|
|
93
|
+
"""独立子进程全新 _load_index + read(旁路读面)。"""
|
|
94
|
+
env = dict(os.environ)
|
|
95
|
+
env["MDCG_MASTER_KEY"] = DUMMY_KEY
|
|
96
|
+
env["PYTHONUTF8"] = "1"
|
|
97
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
98
|
+
env["PYTHONPATH"] = REPO + os.pathsep + env.get("PYTHONPATH", "")
|
|
99
|
+
return subprocess.run(
|
|
100
|
+
[sys.executable, "-X", "utf8", "-c", READ_CHILD, root, REPO, ids],
|
|
101
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
102
|
+
env=env, cwd=REPO)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _snapshot_ids(root):
|
|
106
|
+
"""盘面 _index.json 的节点 id 集(复验证据链 22:14:43 的观察点)。"""
|
|
107
|
+
p = os.path.join(root, "_index.json")
|
|
108
|
+
if not os.path.exists(p):
|
|
109
|
+
return None
|
|
110
|
+
with open(p, encoding="utf-8") as f:
|
|
111
|
+
return set(json.load(f).get("nodes") or {})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _counting(cg):
|
|
115
|
+
"""给 _maybe_reload_index 挂计数桩,返回 (计数 dict, 解挂函数)。"""
|
|
116
|
+
calls = {"probe": 0, "reload": 0}
|
|
117
|
+
orig = cg._maybe_reload_index
|
|
118
|
+
|
|
119
|
+
def counted():
|
|
120
|
+
calls["probe"] += 1
|
|
121
|
+
r = orig()
|
|
122
|
+
if r:
|
|
123
|
+
calls["reload"] += 1
|
|
124
|
+
return r
|
|
125
|
+
|
|
126
|
+
cg._maybe_reload_index = counted
|
|
127
|
+
return calls, (lambda: setattr(cg, "_maybe_reload_index", orig))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
roots = []
|
|
132
|
+
# 纪律:哑主密钥先行——进程内任何 MdCGSecure 构造都不得触真实
|
|
133
|
+
# ~/.mdcg/master.key(_init_crypto 的 env 回落链,mdcos.py)。
|
|
134
|
+
os.environ["MDCG_MASTER_KEY"] = DUMMY_KEY
|
|
135
|
+
try:
|
|
136
|
+
# ---------- ① 红转绿:常驻 A + 独立写方 B(DSH 形态复刻) ----------
|
|
137
|
+
print("\n【①】常驻 A 建库 → 独立 B add+close(compact 落快照)→ 红转绿")
|
|
138
|
+
root1 = tempfile.mkdtemp(prefix="p1b2_1_")
|
|
139
|
+
roots.append(root1)
|
|
140
|
+
a = MdCGSecure(root1, principal=_principal())
|
|
141
|
+
a.add("seed_node_1", "# 功能名:种子节点\n# 正文:常驻 A 的初始记忆",
|
|
142
|
+
layer="knowledge")
|
|
143
|
+
a.flush() # 只追加 _index_log 分片,不动 _index.json
|
|
144
|
+
check("A 自身 flush 不改 _index.json 签名(分片追加不改快照)",
|
|
145
|
+
a._index_sig == a._index_signature())
|
|
146
|
+
b = MdCGSecure(root1, principal=_principal(actor="writer_b"))
|
|
147
|
+
b.add("reload_probe_1", "# 功能名:跨进程探针\n# 正文:B 写入的探针节点",
|
|
148
|
+
layer="knowledge")
|
|
149
|
+
b.close() # close → compact:写 _index.json(review_cli 退出同款)
|
|
150
|
+
ids = _snapshot_ids(root1)
|
|
151
|
+
check("盘面 _index.json 已含 B 的探针(复验证据链 22:14:43 形态)",
|
|
152
|
+
ids is not None and "reload_probe_1" in ids,
|
|
153
|
+
"snapshot_ids=%s" % (sorted(ids or [])[:8]))
|
|
154
|
+
# 红形态:修前无 _maybe_reload_index → 常驻 A 读不到
|
|
155
|
+
orig_reload = a._maybe_reload_index
|
|
156
|
+
a._maybe_reload_index = lambda: False
|
|
157
|
+
red = a.get("reload_probe_1")
|
|
158
|
+
a._maybe_reload_index = orig_reload
|
|
159
|
+
check("红(修前病灶形态):禁用 reload 时常驻 A read 探针 null",
|
|
160
|
+
red is None, repr(red)[:80])
|
|
161
|
+
fired = a._maybe_reload_index()
|
|
162
|
+
check("绿:_maybe_reload_index 签名变化触发重载", fired is True)
|
|
163
|
+
green = a.get("reload_probe_1")
|
|
164
|
+
check("绿:常驻 A read 探针命中", green is not None,
|
|
165
|
+
repr(green)[:80])
|
|
166
|
+
res, _meta = a.search("跨进程探针")
|
|
167
|
+
check("绿:常驻 A search(route/read 链路)命中探针",
|
|
168
|
+
any(n.get("id") == "reload_probe_1" for n, _s, _q in res),
|
|
169
|
+
str([n.get("id") for n, _s, _q in res])[:120])
|
|
170
|
+
# 删除可见性:C 实例 forget → tombstone 经重放对 A 生效
|
|
171
|
+
c = MdCGSecure(root1, principal=_principal(actor="writer_c"))
|
|
172
|
+
fr = c.forget("reload_probe_1", reason="P1b-2 tombstone 腿")
|
|
173
|
+
c.close()
|
|
174
|
+
check("C forget 成功(ok=True)", isinstance(fr, dict) and fr.get("ok"),
|
|
175
|
+
str(fr)[:80])
|
|
176
|
+
check("绿:常驻 A read 已删节点归 null(tombstone 重放)",
|
|
177
|
+
a.get("reload_probe_1") is None)
|
|
178
|
+
a.close()
|
|
179
|
+
|
|
180
|
+
# ---------- ② 旁路新进程可读(修前已绿,钉住盘面账本) ----------
|
|
181
|
+
print("\n【②】旁路全新子进程 read(_load_index 直读盘面)")
|
|
182
|
+
root2 = tempfile.mkdtemp(prefix="p1b2_2_")
|
|
183
|
+
roots.append(root2)
|
|
184
|
+
w = MdCGSecure(root2, principal=_principal(actor="writer_2"))
|
|
185
|
+
w.add("bypass_probe", "# 功能名:旁路探针\n# 正文:新进程应立即可见",
|
|
186
|
+
layer="knowledge")
|
|
187
|
+
w.close()
|
|
188
|
+
r2 = _spawn_read(root2, "bypass_probe,missing_node")
|
|
189
|
+
line = (r2.stdout or "").strip().splitlines()[-1] if (r2.stdout or "").strip() else ""
|
|
190
|
+
check("旁路新进程 read:探针 1 / 缺失 0(READ 1 0)",
|
|
191
|
+
line == "READ 1 0",
|
|
192
|
+
"stdout=%r stderr=%r" % ((r2.stdout or "")[:80], (r2.stderr or "")[:200]))
|
|
193
|
+
|
|
194
|
+
# ---------- ③ 自身写入零重载 + 自写不自载 ----------
|
|
195
|
+
print("\n【③】自身写入/flush/compact/rebuild 全程零真实重载")
|
|
196
|
+
root3 = tempfile.mkdtemp(prefix="p1b2_3_")
|
|
197
|
+
roots.append(root3)
|
|
198
|
+
a2 = MdCGSecure(root3, principal=_principal())
|
|
199
|
+
a2.add("own_seed", "# 功能名:自有种子\n# 正文:own seed", layer="knowledge")
|
|
200
|
+
a2.flush()
|
|
201
|
+
calls, uncount = _counting(a2)
|
|
202
|
+
for i in range(3):
|
|
203
|
+
a2.add("own_%d" % i, "# 功能名:自写%d\n# 正文:own write %d" % (i, i),
|
|
204
|
+
layer="knowledge")
|
|
205
|
+
a2.flush()
|
|
206
|
+
for i in range(3):
|
|
207
|
+
a2.get("own_%d" % i)
|
|
208
|
+
a2.search("自写")
|
|
209
|
+
a2.list_goals()
|
|
210
|
+
check("自身写入+flush+读全程:重载 0 次(写路径不接、flush 不改签名)",
|
|
211
|
+
calls["reload"] == 0, str(calls))
|
|
212
|
+
check("接线在位:读路径确经签名探测(3×get+1×search+1×list_goals=5 次)",
|
|
213
|
+
calls["probe"] >= 5, str(calls))
|
|
214
|
+
a2.compact_index()
|
|
215
|
+
check("compact 后签名已主动刷新(自写不自载前提)",
|
|
216
|
+
a2._index_sig == a2._index_signature())
|
|
217
|
+
a2.get("own_0")
|
|
218
|
+
a2.rebuild_index()
|
|
219
|
+
a2.get("own_0")
|
|
220
|
+
check("compact/rebuild 后紧接读:重载仍 0 次", calls["reload"] == 0,
|
|
221
|
+
str(calls))
|
|
222
|
+
uncount()
|
|
223
|
+
a2.close()
|
|
224
|
+
|
|
225
|
+
# ---------- ④ maintain reload 动作 ----------
|
|
226
|
+
print("\n【④】maintain action=reload 显式探活")
|
|
227
|
+
from .mdcos import MdCGOS
|
|
228
|
+
check('"reload" 已登记 MAINTAIN_ACTIONS', "reload" in MdCGOS.MAINTAIN_ACTIONS,
|
|
229
|
+
str(MdCGOS.MAINTAIN_ACTIONS))
|
|
230
|
+
a3 = MdCGSecure(root3, principal=_principal())
|
|
231
|
+
out0 = a3.maintain(action="reload")
|
|
232
|
+
check("幂等:签名未变时 reloaded=False",
|
|
233
|
+
isinstance(out0, dict) and out0.get("reloaded") is False,
|
|
234
|
+
str(out0)[:120])
|
|
235
|
+
bw = MdCGSecure(root3, principal=_principal(actor="writer_4"))
|
|
236
|
+
bw.add("maintain_probe", "# 功能名:维护探针\n# 正文:maintain reload 腿",
|
|
237
|
+
layer="knowledge")
|
|
238
|
+
bw.close()
|
|
239
|
+
out1 = a3.maintain(action="reload")
|
|
240
|
+
check("他进程写快照后 maintain reload 返回 reloaded=True",
|
|
241
|
+
isinstance(out1, dict) and out1.get("reloaded") is True,
|
|
242
|
+
str(out1)[:120])
|
|
243
|
+
check("探活后读面立即可见新节点", a3.get("maintain_probe") is not None)
|
|
244
|
+
a3.close()
|
|
245
|
+
|
|
246
|
+
# ---------- ⑤ goal=list 链路(list_goals 接线腿) ----------
|
|
247
|
+
print("\n【⑤】goal 跨进程可见(list_goals / active_goals)")
|
|
248
|
+
root4 = tempfile.mkdtemp(prefix="p1b2_4_")
|
|
249
|
+
roots.append(root4)
|
|
250
|
+
a4 = MdCGSecure(root4, principal=_principal())
|
|
251
|
+
check("常驻 A' 初始无目标", a4.list_goals() == [])
|
|
252
|
+
gw = MdCGSecure(root4, principal=_principal(actor="writer_5"))
|
|
253
|
+
gid = gw.add_goal("跨进程目标探针:P1b-2 list_goals 接线腿", priority=0.9)
|
|
254
|
+
gw.close()
|
|
255
|
+
gl = a4.list_goals()
|
|
256
|
+
check("goal=list 经 list_goals 重载后可见",
|
|
257
|
+
any(g.get("id") == gid for g in gl),
|
|
258
|
+
str([g.get("id") for g in gl])[:120])
|
|
259
|
+
ag = a4.active_goals()
|
|
260
|
+
check("active_goals 同源可见",
|
|
261
|
+
any(g.get("id") == gid for g in ag))
|
|
262
|
+
a4.close()
|
|
263
|
+
|
|
264
|
+
# ---------- ⑥ 性能粗测:无跨进程写入时逐读开销 ----------
|
|
265
|
+
print("\n【⑥】性能粗测(无跨进程写入,接线态 vs 重载桩空转态)")
|
|
266
|
+
N = 300
|
|
267
|
+
a2c = MdCGSecure(root3, principal=_principal())
|
|
268
|
+
for _ in range(5):
|
|
269
|
+
a2c.get("own_0")
|
|
270
|
+
t0 = time.perf_counter()
|
|
271
|
+
for _ in range(N):
|
|
272
|
+
a2c.get("own_0")
|
|
273
|
+
t_on = (time.perf_counter() - t0) / N * 1e6
|
|
274
|
+
orig = a2c._maybe_reload_index
|
|
275
|
+
a2c._maybe_reload_index = lambda: False
|
|
276
|
+
for _ in range(5):
|
|
277
|
+
a2c.get("own_0")
|
|
278
|
+
t0 = time.perf_counter()
|
|
279
|
+
for _ in range(N):
|
|
280
|
+
a2c.get("own_0")
|
|
281
|
+
t_off = (time.perf_counter() - t0) / N * 1e6
|
|
282
|
+
a2c._maybe_reload_index = orig
|
|
283
|
+
a2c.close()
|
|
284
|
+
print(" 接线态 %.1f µs/读 · 桩空转态 %.1f µs/读 · 增幅 %.1f%%"
|
|
285
|
+
% (t_on, t_off, (t_on - t_off) / max(t_off, 1e-9) * 100))
|
|
286
|
+
check("性能上界:接线态 ≤ 桩空转态×3 + 30µs(stat 微秒级;宽松防抖)",
|
|
287
|
+
t_on <= t_off * 3 + 30.0,
|
|
288
|
+
"on=%.1f off=%.1f" % (t_on, t_off))
|
|
289
|
+
finally:
|
|
290
|
+
for r_ in roots:
|
|
291
|
+
shutil.rmtree(r_, ignore_errors=True)
|
|
292
|
+
|
|
293
|
+
print("\n" + "=" * 64)
|
|
294
|
+
print(f"PASS={PASS} FAIL={FAIL}")
|
|
295
|
+
if FAILS:
|
|
296
|
+
print("失败项:" + ";".join(FAILS))
|
|
297
|
+
return 0 if FAIL == 0 else 1
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
sys.exit(main())
|