@furongjun1999/dsh-memory 0.6.0 → 0.6.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.
Files changed (170) hide show
  1. package/README.md +588 -572
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
  8. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
  9. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
  10. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
  11. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
  12. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
  13. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
  14. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  15. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  16. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
  17. 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_v20.md +283 -0
  18. 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_v21.md +224 -0
  19. 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_v22.md +223 -0
  20. 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_v23.md +293 -0
  21. 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_v24.md +303 -0
  22. 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_v25.md +230 -0
  23. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
  24. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  25. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  26. 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 +40 -40
  27. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  28. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  29. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
  30. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
  31. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  32. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  33. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
  34. package/lib/bridge.js +24 -2
  35. package/lib/hooks.d.ts +31 -2
  36. package/lib/hooks.js +219 -16
  37. package/lib/init.js +16 -1
  38. package/lib/lib/prompt_safety.d.ts +52 -1
  39. package/lib/lib/prompt_safety.js +76 -1
  40. package/md_cg/audit.py +652 -379
  41. package/md_cg/bench6_arms.py +1 -1
  42. package/md_cg/bench_p0.py +1 -1
  43. package/md_cg/branches.py +2 -2
  44. package/md_cg/ccgc.py +1071 -1006
  45. package/md_cg/chain.py +1 -1
  46. package/md_cg/consistency.py +97 -9
  47. package/md_cg/consolidate.py +34 -9
  48. package/md_cg/crypto.py +59 -26
  49. package/md_cg/docindex.py +16 -1
  50. package/md_cg/evolution.py +17 -1
  51. package/md_cg/export.py +1 -1
  52. package/md_cg/forgetting.py +309 -13
  53. package/md_cg/fsutil.py +454 -8
  54. package/md_cg/identity.py +3 -3
  55. package/md_cg/insight.py +24 -3
  56. package/md_cg/linkref.py +1 -1
  57. package/md_cg/logref.py +327 -0
  58. package/md_cg/mcp_server.py +4152 -3818
  59. package/md_cg/mdcg.py +4208 -3547
  60. package/md_cg/mdcos.py +4491 -4159
  61. package/md_cg/mreview/pipeline.py +15 -1
  62. package/md_cg/nodefile.py +639 -575
  63. package/md_cg/protect.py +158 -11
  64. package/md_cg/protocol.py +41 -2
  65. package/md_cg/provenance.py +22 -4
  66. package/md_cg/reach.py +4 -4
  67. package/md_cg/readcache.py +76 -15
  68. package/md_cg/reconcile.py +1 -1
  69. package/md_cg/refindex.py +246 -37
  70. package/md_cg/refine.py +1 -1
  71. package/md_cg/routing.py +32 -5
  72. package/md_cg/run_tests.py +17 -0
  73. package/md_cg/scrub.py +30 -3
  74. package/md_cg/security.py +47 -1
  75. package/md_cg/self_state.py +5 -5
  76. package/md_cg/sources.py +23 -6
  77. package/md_cg/srcindex.py +352 -0
  78. package/md_cg/stg.py +47 -3
  79. package/md_cg/subgraph.py +2 -2
  80. package/md_cg/sustain.py +1299 -1168
  81. package/md_cg/tasks.py +469 -470
  82. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  83. package/md_cg/test_b1b2_write_face.py +417 -0
  84. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  85. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  86. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  87. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  88. package/md_cg/test_c3_transient_read_negative.py +793 -0
  89. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  90. package/md_cg/test_ccg_form_parity.py +188 -0
  91. package/md_cg/test_ccgc.py +28 -3
  92. package/md_cg/test_govern_directread.py +17 -4
  93. package/md_cg/test_h2_session_view_norm.py +233 -0
  94. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  95. package/md_cg/test_hive_ingest.py +285 -285
  96. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  97. package/md_cg/test_issue43_default_policy.py +865 -0
  98. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  99. package/md_cg/test_linkref.py +10 -3
  100. package/md_cg/test_lock.py +2 -2
  101. package/md_cg/test_logref.py +1109 -0
  102. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  103. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  104. package/md_cg/test_mr_m2.py +15 -3
  105. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  106. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  107. package/md_cg/test_n176_link_trust.py +221 -0
  108. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  109. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  110. package/md_cg/test_n195_writepath_reload.py +283 -0
  111. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  112. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  113. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  114. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  115. package/md_cg/test_n201_proposal_visibility.py +382 -0
  116. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  117. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  118. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  119. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  120. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  121. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  122. package/md_cg/test_n225_nonobject_load.py +1110 -0
  123. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  124. package/md_cg/test_neg_condition_hits.py +333 -0
  125. package/md_cg/test_neg_tail_honesty.py +663 -0
  126. package/md_cg/test_none_id_write_guard.py +4 -3
  127. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  128. package/md_cg/test_p1.py +5 -5
  129. package/md_cg/test_p11_consistency.py +161 -12
  130. package/md_cg/test_p26_refindex.py +2 -2
  131. package/md_cg/test_p27_docindex.py +777 -774
  132. package/md_cg/test_p28_refcheck.py +740 -13
  133. package/md_cg/test_p29_session_ingest_export.py +2 -2
  134. package/md_cg/test_p2_mcp.py +12 -1
  135. package/md_cg/test_p30_maintain.py +33 -2
  136. package/md_cg/test_p31_insight.py +1 -0
  137. package/md_cg/test_p9c_dedup_hints.py +276 -0
  138. package/md_cg/test_policy_required_ccg.py +426 -0
  139. package/md_cg/test_protocol.py +22 -0
  140. package/md_cg/test_rank_parity_score_mode.py +516 -0
  141. package/md_cg/test_read_face_input_gates.py +363 -0
  142. package/md_cg/test_read_face_semantics.py +489 -0
  143. package/md_cg/test_recall_face_guards.py +812 -0
  144. package/md_cg/test_rejected_credential_forms.py +188 -0
  145. package/md_cg/test_rejected_redact.py +146 -0
  146. package/md_cg/test_retr_s1b.py +2 -2
  147. package/md_cg/test_retr_s5.py +20 -7
  148. package/md_cg/test_review_conformance.py +21 -3
  149. package/md_cg/test_security_audit_v21.py +2 -2
  150. package/md_cg/test_server_version.py +67 -0
  151. package/md_cg/test_srcindex.py +171 -0
  152. package/md_cg/test_tenant_env_override_warn.py +63 -50
  153. package/md_cg/test_token_lowercase_form.py +315 -0
  154. package/md_cg/test_v21r1_package_version.py +310 -0
  155. package/md_cg/test_writepipe.py +10 -4
  156. package/md_cg/tokens.py +225 -95
  157. package/md_cg/tool_face.py +4 -4
  158. package/md_cg/trust.py +49 -5
  159. package/md_cg/units.py +204 -6
  160. package/md_cg/weights.py +4 -4
  161. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  162. package/md_cg/writelimit.py +42 -8
  163. package/md_cg/writepipe.py +651 -554
  164. package/package.json +3 -2
  165. package/skills/plugin.json +1 -1
  166. package/src/bridge.ts +25 -2
  167. package/src/hooks.ts +229 -16
  168. package/src/init.ts +15 -1
  169. package/src/lib/prompt_safety.ts +88 -1
  170. package/zcode/AGENTS.md +10 -10
package/md_cg/fsutil.py CHANGED
@@ -263,6 +263,230 @@ def read_jsonl_tail(path: str, offset: int):
263
263
  _COUNT_CACHE = {} # abspath -> (bytes_scanned, mtime_ns, lines)
264
264
 
265
265
 
266
+ # ---------- N225(2026-09-29):非对象记录行的可观测记账 ----------
267
+ #
268
+ # 分片日志的读面必须**容忍**非对象行(`null` / `[]` / `123` / `"abc"` 都是
269
+ # 合法 JSON,`read_jsonl` 按「JSON 合法性」收行 ⇒ 它们会进调用方的记录
270
+ # 列表),否则排序键 `r.get("_t", 0)` 抛 AttributeError,索引装载 / compact
271
+ # 整链断裂(实测:MdCG 构造失败、常驻服务起不来)。但**容忍 ≠ 静默**:
272
+ # 跳过必须留下机器可读的痕迹——否则「日志里混进了坏行」与「记录本就不
273
+ # 存在」不可区分,索引静默少几条,运维与守卫都看不见。故此处是跳过面的
274
+ # 唯一记账点:模块级累计计数 + 有界样本 + stderr 汇总告警(每条分片一行,
275
+ # 不逐行刷屏)。
276
+ #
277
+ # N225 补强(2026-09-29,深度 2 同族面):同一记账面再收两类**dict 行内**的坏型
278
+ # (N225 v1.0 报告 §0「已知缺口」点名的深度 2 复发面,此前不在任何断言面内):
279
+ # ① 载荷坏型:分片记录 `e` 非 dict 且非 None——判据在重放面(mdcg 的两条重放
280
+ # 路径同一实现),经 note_bad_payload_rows 记账;
281
+ # ② 排序键坏型:`_t` / `_s` 非数值(或 NaN)——read_all 的排序键健壮化与记账
282
+ # 必须同时发生,经 note_bad_sortkey_rows 记账。
283
+ # 三类**合计**进 NONOBJECT_ROW_SKIPS(这就是「同一记账面」的字面口径),各子面
284
+ # 另留独立计数与样本,运维据此区分「行根本不是对象」/「行是对象而载荷坏了」/
285
+ # 「行的排序键坏了」——三者排查方向不同。kind 名:非对象行 / 载荷坏型 / 排序键坏型。
286
+ NONOBJECT_ROW_SKIPS = 0 # 累计跳过的坏记录行数(三类合计,进程内)
287
+ NONOBJECT_ROW_SAMPLES = [] # 非对象行样本:(分片路径, 行号, JSON 类型名)
288
+ BAD_PAYLOAD_ROW_SKIPS = 0 # 载荷坏型子面:(e 非 dict 且非 None) 条数
289
+ BAD_PAYLOAD_ROW_SAMPLES = [] # 载荷坏型样本:(来源标签, 记录 id, 载荷类型名)
290
+ BAD_SORTKEY_ROW_SKIPS = 0 # 排序键坏型子面:(_t/_s 非数值或 NaN) 条数
291
+ BAD_SORTKEY_ROW_SAMPLES = [] # 排序键坏型样本:(分片路径, 行号, (t 类型名, s 类型名))
292
+ _NONOBJECT_SAMPLE_CAP = 32 # 样本上限:记账要有界,不随坏行线性涨
293
+
294
+
295
+ # 生效条件:skips 为 (分片路径, 行号, 值) 三元组序列且非空时,把条数累加进 NONOBJECT_ROW_SKIPS、按 _NONOBJECT_SAMPLE_CAP 上限补样本,并向 sys.stderr 写一行汇总告警(含分片名、本次条数、首条行号与类型名、累计条数);skips 为空序列或假值时立即返回、不写任何输出。
296
+ def note_nonobject_rows(skips):
297
+ """登记一批被跳过的非对象记录行 + stderr 告警(N225 可观测面)。"""
298
+ if not skips:
299
+ return
300
+ global NONOBJECT_ROW_SKIPS
301
+ NONOBJECT_ROW_SKIPS += len(skips)
302
+ for item in skips:
303
+ if len(NONOBJECT_ROW_SAMPLES) >= _NONOBJECT_SAMPLE_CAP:
304
+ break
305
+ NONOBJECT_ROW_SAMPLES.append((item[0], item[1],
306
+ type(item[2]).__name__))
307
+ path, lineno, value = skips[0]
308
+ sys.stderr.write(
309
+ "[fsutil] ShardedLog.read_all 跳过 %d 条非对象记录行(分片 %s,"
310
+ "首条 行%d 类型=%s)——坏行不进索引重放;本次后进程内累计 %d 条。"
311
+ "排查方向:该分片被非本协议写入方污染,或发生过截断 / 粘连。\n"
312
+ % (len(skips), os.path.basename(path), lineno,
313
+ type(value).__name__, NONOBJECT_ROW_SKIPS))
314
+
315
+
316
+ # 生效条件:无入参,返回二元组 (累计坏行条数, 非对象行样本元组副本)——累计数为三类合计(非对象行 + 载荷坏型 + 排序键坏型),样本元素为 (分片路径, 行号, JSON 类型名) 且只含非对象行一类,副本只读、调用方改动不影响记账面。
317
+ def nonobject_row_stats():
318
+ """读坏行记账合计(只读):(累计条数, 非对象行样本元组)。
319
+
320
+ 累计数是**同一记账面**的合计(三类 kind 都计入);按 kind 的分项见
321
+ bad_payload_row_stats / bad_sortkey_row_stats。
322
+ """
323
+ return NONOBJECT_ROW_SKIPS, tuple(NONOBJECT_ROW_SAMPLES)
324
+
325
+
326
+ # 生效条件:skips 为 (来源标签, 记录 id, 载荷值) 三元组序列且非空时,把 len(skips) 同时累加进 NONOBJECT_ROW_SKIPS(同一记账面)与 BAD_PAYLOAD_ROW_SKIPS(子面),按 _NONOBJECT_SAMPLE_CAP 上限补 BAD_PAYLOAD_ROW_SAMPLES(元素为 (来源标签, str(记录 id), 载荷类型名)),并向 sys.stderr 写一行载荷坏型汇总告警(含来源基名、本次条数、首条 id 与类型名、进程内累计条数);skips 为空序列或假值时立即返回——不写输出、不动任何计数。
327
+ def note_bad_payload_rows(skips):
328
+ """登记一批被跳过的**载荷坏型**记录(`e` 非 dict 且非 None)+ stderr 告警。
329
+
330
+ 与 note_nonobject_rows 同一个记账面(合计进 NONOBJECT_ROW_SKIPS),但样本
331
+ 与告警文案分开:运维据此区分「行本身不是对象」与「行是对象而载荷坏了」。
332
+ 记录**不落进索引**由调用方(重放路径)负责,本函数只记账。计数只在这里加,
333
+ 调用方不得另立计数器(`_count_buckets` 那条下游不做类型检查,正是靠这里
334
+ 把坏载荷挡在 `nodes` 之外)。
335
+ """
336
+ if not skips:
337
+ return
338
+ global NONOBJECT_ROW_SKIPS, BAD_PAYLOAD_ROW_SKIPS
339
+ NONOBJECT_ROW_SKIPS += len(skips)
340
+ BAD_PAYLOAD_ROW_SKIPS += len(skips)
341
+ for item in skips:
342
+ if len(BAD_PAYLOAD_ROW_SAMPLES) >= _NONOBJECT_SAMPLE_CAP:
343
+ break
344
+ BAD_PAYLOAD_ROW_SAMPLES.append((item[0], str(item[1]),
345
+ type(item[2]).__name__))
346
+ source, nid, value = skips[0]
347
+ sys.stderr.write(
348
+ "[fsutil] 分片重放跳过 %d 条载荷坏型记录(e 非 dict 且非 None,来源 %s,"
349
+ "首条 id=%s 载荷类型=%s)——坏载荷不进索引节点;本次后进程内累计 %d 条"
350
+ "(含非对象行)。排查方向:该分片被按别的协议写入,或载荷被外部改写。\n"
351
+ % (len(skips), os.path.basename(str(source)), nid,
352
+ type(value).__name__, NONOBJECT_ROW_SKIPS))
353
+
354
+
355
+ # 生效条件:无入参,返回二元组 (载荷坏型累计条数, 样本元组副本)——样本元素为 (来源标签, 记录 id, 载荷类型名),副本只读、调用方改动不影响记账面。
356
+ def bad_payload_row_stats():
357
+ """读载荷坏型子面(只读):(累计条数, 样本元组)。"""
358
+ return BAD_PAYLOAD_ROW_SKIPS, tuple(BAD_PAYLOAD_ROW_SAMPLES)
359
+
360
+
361
+ # 生效条件:skips 为 (分片路径, 行号, (t 值, s 值)) 三元组序列且非空时,把 len(skips) 同时累加进 NONOBJECT_ROW_SKIPS(同一记账面)与 BAD_SORTKEY_ROW_SKIPS(子面),按 _NONOBJECT_SAMPLE_CAP 上限补 BAD_SORTKEY_ROW_SAMPLES(元素为 (分片路径, 行号, (t 类型名, s 类型名))),并向 sys.stderr 写一行排序键坏型汇总告警(含分片基名、本次条数、首条行号与两个类型名、进程内累计条数);skips 为空序列或假值时立即返回——不写输出、不动任何计数。
362
+ def note_bad_sortkey_rows(skips):
363
+ """登记一批**排序键坏型**记录(`_t` / `_s` 非数值或 NaN)+ stderr 告警。
364
+
365
+ 与 note_nonobject_rows 同一记账面。**记录本身不丢**(仍是 dict,照常回放,
366
+ 只在排序键上按「缺键取 0」的既有口径归一)——故本函数只记账、不改记录:
367
+ 丢掉一条合法 dict 记录是比重排它更糟的事。
368
+ """
369
+ if not skips:
370
+ return
371
+ global NONOBJECT_ROW_SKIPS, BAD_SORTKEY_ROW_SKIPS
372
+ NONOBJECT_ROW_SKIPS += len(skips)
373
+ BAD_SORTKEY_ROW_SKIPS += len(skips)
374
+ for item in skips:
375
+ if len(BAD_SORTKEY_ROW_SAMPLES) >= _NONOBJECT_SAMPLE_CAP:
376
+ break
377
+ raw = item[2]
378
+ BAD_SORTKEY_ROW_SAMPLES.append(
379
+ (item[0], item[1], (type(raw[0]).__name__, type(raw[1]).__name__)))
380
+ path, lineno, raw = skips[0]
381
+ sys.stderr.write(
382
+ "[fsutil] ShardedLog.read_all 排序键坏型 %d 条(分片 %s,首条 行%d "
383
+ "_t=%s _s=%s)——坏型槽记 0 参与排序(记录不丢);本次后进程内累计 %d 条"
384
+ "(含非对象行)。排查方向:该分片被非本协议写入方污染。\n"
385
+ % (len(skips), os.path.basename(path), lineno,
386
+ type(raw[0]).__name__, type(raw[1]).__name__, NONOBJECT_ROW_SKIPS))
387
+
388
+
389
+ # 生效条件:无入参,返回二元组 (排序键坏型累计条数, 样本元组副本)——样本元素为 (分片路径, 行号, (t 类型名, s 类型名)),副本只读、调用方改动不影响记账面。
390
+ def bad_sortkey_row_stats():
391
+ """读排序键坏型子面(只读):(累计条数, 样本元组)。"""
392
+ return BAD_SORTKEY_ROW_SKIPS, tuple(BAD_SORTKEY_ROW_SAMPLES)
393
+
394
+
395
+ # 生效条件:无入参,把三类累计条数(NONOBJECT_ROW_SKIPS / BAD_PAYLOAD_ROW_SKIPS / BAD_SORTKEY_ROW_SKIPS)全置 0 并把三个样本列表原地清空(del [:],不换对象);守卫与运维建立观测基线时用,生产读路径不调用。
396
+ def reset_nonobject_row_stats():
397
+ """清空整个坏行记账面(含两类子面;守卫 / 运维的观测基线用)。"""
398
+ global NONOBJECT_ROW_SKIPS, BAD_PAYLOAD_ROW_SKIPS, BAD_SORTKEY_ROW_SKIPS
399
+ NONOBJECT_ROW_SKIPS = 0
400
+ BAD_PAYLOAD_ROW_SKIPS = 0
401
+ BAD_SORTKEY_ROW_SKIPS = 0
402
+ del NONOBJECT_ROW_SAMPLES[:]
403
+ del BAD_PAYLOAD_ROW_SAMPLES[:]
404
+ del BAD_SORTKEY_ROW_SAMPLES[:]
405
+
406
+
407
+ # ---------- C-3(FI-M02 / N134,2026-09-29):读失败的**瞬时/终态单点判别**与记账 ----------
408
+ #
409
+ # 缺陷(N134,docs/eval/缺陷挖掘_自主迭代_v16.md:86):`MdCG._read` 把 OSError
410
+ # (含**瞬态**失败:独占句柄 / 资源剥夺)与「文件不存在 / 越界」一律归成
411
+ # `(None, None)`,readcache 又把任何返回值——包括 `(None, None)`——一律当正常值
412
+ # 写入缓存 ⇒ 一次瞬态 OS 失败被固化成「该节点从检索面永久消失,直到进程重启或
413
+ # 该 path 再写盘」(cache 条目字面 `(gen, (None, None))`);而 `cg.get` 直读不走
414
+ # 缓存照常可读 ⇒「get 能读、search 搜不到」撕裂(P1 fail-closed 缺席 + T4 静默损伤)。
415
+ #
416
+ # 修法要点:**判别只此一处**——读路径在唯一捕获 OSError 的点上调用本函数拿到
417
+ # 「瞬时 / 终态」标签,缓存层按标签决定接纳与否,**不再自行回看异常类型**
418
+ # (否则就是判据的第二份副本;本仓 N133 与 `_ccg_line` 的教训都是「副本自称同源」)。
419
+ # · 终态(READ_FAIL_ABSENT):`FileNotFoundError`——`atomic_write` 是
420
+ # tmp + os.replace(见 `_publish`:读者只可能看到旧值或新值,不会看到
421
+ # 「写一半的缺文件」),故 ENOENT 是**真缺**,可照旧入缓存(不构成重读风暴)。
422
+ # 越界(`_node_disk_path` 抛 ValueError)与「有节点但无密钥」同属终态,
423
+ # 且不经本函数——两条既有语义逐位不变。
424
+ # · 瞬时(READ_FAIL_TRANSIENT):其余 OSError——Windows 上独占句柄
425
+ # (WinError 32 共享冲突)、权限剥夺、同名目录顶位、磁盘瞬时故障。
426
+ # **判据方向是 fail-closed 的**:判不准(非 FileNotFoundError 的一切)
427
+ # 一律归「瞬时」——宁可多读一次,不可把可读节点判死。
428
+ #
429
+ # ③「不得静默」:与 N225 三类坏行记账同风格——模块级累计计数 + 有界样本 +
430
+ # stderr 汇总告警;计数面经 `transient_read_stats()` 可被守卫/运维读取。
431
+ READ_FAIL_ABSENT = "absent" # 终态:真缺 / 越界 / 无密钥 → 可入缓存
432
+ READ_FAIL_TRANSIENT = "transient" # 瞬时:可重试 → **不得**以「新鲜」身份固化
433
+
434
+ TRANSIENT_READ_FAILURES = 0 # 累计瞬时读失败次数(进程内)
435
+ TRANSIENT_READ_SAMPLES = [] # 样本:(节点 path, 异常类型名, errno)
436
+ _TRANSIENT_READ_SAMPLE_CAP = 32 # 样本上限:记账要有界,不随失败次数线性涨
437
+ # stderr 告警上限:热路径(全池检索每查询逐条读)防刷屏;**计数面恒完整**,
438
+ # 上限只压告警行数,不减信息可观测性(守卫读的是计数与样本,不是 stderr)。
439
+ _TRANSIENT_READ_WARN_CAP = 32
440
+
441
+
442
+ # 生效条件:exc 为读节点文件时捕获的异常对象;是 FileNotFoundError(含其子类)返回 READ_FAIL_ABSENT(终态:真缺,可入缓存),否则返回 READ_FAIL_TRANSIENT(瞬时:可重试,不得入缓存);非 OSError 入参同样按瞬时返回(判不准即保守,绝不判死节点)。
443
+ def classify_read_failure(exc) -> str:
444
+ """**单点**判别:读失败是瞬时的还是终态的(唯一真源,缓存层不得再猜一遍)。
445
+
446
+ 调用面恒为读路径捕获 OSError 的那一处(`MdCG._note_read_oserror`)。
447
+ """
448
+ if isinstance(exc, FileNotFoundError):
449
+ return READ_FAIL_ABSENT
450
+ return READ_FAIL_TRANSIENT
451
+
452
+
453
+ # 生效条件:path 为节点文件路径(任意值,str() 后取基名入样本)、exc 为捕获到的异常;无条件把 TRANSIENT_READ_FAILURES 累加 1、按 _TRANSIENT_READ_SAMPLE_CAP 上限补样本,并在累计次数不超过 _TRANSIENT_READ_WARN_CAP 时向 sys.stderr 写一行汇总告警;返回是否写了告警行(bool)。
454
+ def note_transient_read_failure(path, exc) -> bool:
455
+ """登记一次**瞬时读失败** + stderr 告警(C-3 可观测面,N225 同风格)。
456
+
457
+ 只记账,**不改任何调用方的控制流**——读不到仍是读不到,只是不再静默。
458
+ """
459
+ global TRANSIENT_READ_FAILURES
460
+ TRANSIENT_READ_FAILURES += 1
461
+ if len(TRANSIENT_READ_SAMPLES) < _TRANSIENT_READ_SAMPLE_CAP:
462
+ TRANSIENT_READ_SAMPLES.append(
463
+ (str(path), type(exc).__name__, getattr(exc, "errno", None)))
464
+ if TRANSIENT_READ_FAILURES > _TRANSIENT_READ_WARN_CAP:
465
+ return False
466
+ sys.stderr.write(
467
+ "[fsutil] 节点读失败(瞬时,可重试)%s:%s errno=%s——本次不计入检索面,"
468
+ "且**不当作「不存在」固化**(C-3:负结果不入读缓存);进程内累计 %d 次。"
469
+ "排查方向:独占句柄(Windows 共享冲突)/ 权限剥夺 / 同名目录顶位 / "
470
+ "磁盘瞬时故障。\n"
471
+ % (os.path.basename(str(path)), type(exc).__name__,
472
+ getattr(exc, "errno", None), TRANSIENT_READ_FAILURES))
473
+ return True
474
+
475
+
476
+ # 生效条件:无入参,返回二元组 (累计瞬时读失败次数, 样本元组副本)——样本元素为 (节点 path, 异常类型名, errno),副本只读、调用方改动不影响记账面。
477
+ def transient_read_stats():
478
+ """读瞬时读失败记账(只读):(累计次数, 样本元组)。"""
479
+ return TRANSIENT_READ_FAILURES, tuple(TRANSIENT_READ_SAMPLES)
480
+
481
+
482
+ # 生效条件:无入参,把 TRANSIENT_READ_FAILURES 置 0 并原地清空 TRANSIENT_READ_SAMPLES(del [:],不换对象);守卫/运维建立观测基线时用,生产读路径不调用。
483
+ def reset_transient_read_stats():
484
+ """清空瞬时读失败记账面(守卫 / 运维的观测基线用)。"""
485
+ global TRANSIENT_READ_FAILURES
486
+ TRANSIENT_READ_FAILURES = 0
487
+ del TRANSIENT_READ_SAMPLES[:]
488
+
489
+
266
490
  # 生效条件:os.stat(os.path.abspath(path or "")) 抛 OSError 时返回 0;缓存命中且已扫字节数与 mtime_ns 均与 stat 一致时直接返回缓存计数;若缓存已扫字节 < 当前 size 且 mtime_ns 不同则从该偏移起按 chunk 分块累计 b"\n" 个数并加上缓存值;读文件抛 OSError 时返回 total or 0(已累计值为假则返回 0)。
267
491
  def count_jsonl(path: str, chunk: int = 1 << 20) -> int:
268
492
  """数 append-only 日志的行数——**流式计数、不物化**(内存 O(1))。
@@ -314,7 +538,157 @@ def count_jsonl(path: str, chunk: int = 1 << 20) -> int:
314
538
  return total
315
539
 
316
540
 
317
- # 生效条件:directory 经 abspath 后作为分片目录并 makedirs(exist_ok=True),实例分片文件名由 os.getpid() 与 uuid.uuid4().hex[:8] 拼成 "{pid}-{hex8}.log",append 时各写者只写自己这一分片,从而不共享写入点。
541
+ # ---------- B4(2026-09-30):分片目录缺失自愈(容忍 ≠ 静默;失败结构化) ----------
542
+ #
543
+ # 病灶:`ShardedLog` 只在 `__init__` 里建目录,`append` 首次 `open(self.path, "a")`
544
+ # 前不复查。**长驻进程**(生产形态 `MdCGSecure(root, principal, autoflush=1)`——
545
+ # `MdCG.flush` 每批写完全部 `close()` 分片句柄,故下次 append 必经重开)持有过
546
+ # `ShardedLog` 实例之后目录被删(外部清理脚本 / 误删 / 备份还原),此后每次
547
+ # append 都在 open 处抛 `FileNotFoundError [Errno 2]`,**且永不恢复**:目录三次
548
+ # 都不重建、`close()` 抛同异常、`_index.json` 从未落成,而正文 .md 已在盘上
549
+ # (`MdCG.add` → `_write_node` 先写盘)——索引静默落后于盘面。不自愈的必要条件
550
+ # 是「本进程已持有 ShardedLog 实例」:`MdCG.flush` 只在该属性为 None 时建实例
551
+ # (`mdcg.py` 内 `ShardedLog(...)` 构造点全仓唯一),全新短命进程做同一操作时
552
+ # `__init__` 的 makedirs 生效、目录会被重建
553
+ # (2026-09-30 沙箱实测两侧,见 `test_b4_shard_dir_selfheal.py` 头注)。
554
+ #
555
+ # 收口口径(本块是**唯一**实现;第二处调用点一律委托 `ensure_shard_dir`):
556
+ # ① 判据 = 「分片目录不存在就重建」——`isdir` 快路径 → `makedirs(exist_ok=True)`
557
+ # → 失败抛结构化错误;`__init__`(首建)与 `append`(打开前)都走它。
558
+ # ② **容忍 ≠ 静默**(本仓 N225 已确立的判据):重建必须可观测——模块级累计
559
+ # 计数 + 有界样本 + stderr 汇总告警(与 `note_nonobject_rows` 同一风格:
560
+ # 计数只在这里加,调用方不得另立计数器)。**首建不记账**(`where="init"`:
561
+ # 建库时目录本就不存在,那是正常路径,不是「被删后自愈」的病态事件——
562
+ # 计入会让计数面变成噪声、真事件被淹没)。
563
+ # ③ 重建**失败**(父只读 / 权限不足 / 有文件占着该路径):抛 `ShardDirError`
564
+ # —— 可机读 `code` + 可操作 `hint`,不让裸 `FileNotFoundError` 冒到 MCP
565
+ # 出口(出口只渲染 `f"{type(exc).__name__}: {exc}"` + `getattr(exc,
566
+ # "hint")`,裸异常在那里既无 code 也无 hint)。失败**同样记账 + 告警**:
567
+ # 异常可能被上层兜底吞掉(`MdCG.close` 的 `except (OSError, ValueError)`),
568
+ # 痕迹不得只存在于异常里。
569
+ # ④ `read_all` 既有语义**一字不改**:directory 不是目录 → 返回 `[]`(那是读面
570
+ # 的降级契约,与本块写面自愈无关)。
571
+ SHARD_DIR_REBUILDS = 0 # 累计「目录缺失→重建成功」次数(进程内)
572
+ SHARD_DIR_REBUILD_SAMPLES = [] # 重建样本:(目录绝对路径, 调用点标签)
573
+ SHARD_DIR_HEAL_FAILURES = 0 # 累计「目录缺失且重建失败」次数(进程内)
574
+ SHARD_DIR_HEAL_FAILURE_SAMPLES = [] # 失败样本:(目录绝对路径, 调用点, 异常类型名)
575
+ _SHARD_DIR_SAMPLE_CAP = 8 # 样本上限:记账有界,不随事件线性涨
576
+
577
+ # 结构化失败的稳定 code(进 message 首字段 ⇒ MCP 出口的 error 串里可机读;
578
+ # 出口不改形状,与 AccessDenied 的 hint 同口径只透 `hint`)。
579
+ SHARD_DIR_ERR_CODE = "E_SHARD_DIR_UNREBUILDABLE"
580
+
581
+
582
+ # 生效条件:msg 为必填字符串(经 super().__init__ 原样成为 str(e)),code / hint / path 任选(缺省 None);构造出的是 OSError 子类实例,三者分别存入 self.code / self.hint / self.path,不校验取值、不读盘、不抛异常。
583
+ class ShardDirError(OSError):
584
+ """分片目录缺失且无法重建——结构化失败(可机读 code + 可操作 hint)。
585
+
586
+ 为什么继承 `OSError`(而不是 RuntimeError):既有调用面按 `except OSError`
587
+ 收敛 I/O 失败(`MdCG.close` 的兜底、各处 `except (OSError, ValueError)`),
588
+ 换基类会让这些既有的降级/兜底面行为漂移;本类要补的是**信息**(code /
589
+ hint),不是新的异常族。`.hint` 与 `security.AccessDenied` 同口径——MCP
590
+ 出口的 `getattr(exc, "hint")` 会把它渲染进工具错误结果。
591
+ """
592
+
593
+ # 生效条件:msg 为必填字符串(经 super().__init__ 原样成为 str(e)),code / hint / path 任选(缺省 None);随后把三者分别存入 self.code / self.hint / self.path,不校验取值、不读盘。
594
+ def __init__(self, msg: str, code: str = None, hint: str = None,
595
+ path: str = None):
596
+ super().__init__(msg)
597
+ self.code = code
598
+ self.hint = hint
599
+ self.path = path
600
+
601
+
602
+ # 生效条件:where 非 "init" 时把 SHARD_DIR_REBUILDS 累加 1、按 _SHARD_DIR_SAMPLE_CAP 上限补 (目录绝对路径, where) 样本,并向 sys.stderr 写一行含目录名/调用点/累计次数与排查方向的重建告警;where == "init" 时立即返回(首建是正常路径,不记账不告警);stderr 写失败被吞掉(告警面不得反向破坏写路径),计数与样本不受影响。
603
+ def note_shard_dir_rebuild(directory, where: str = "append"):
604
+ """登记一次分片目录重建(成功)+ stderr 告警(B4 可观测面)。"""
605
+ if where == "init":
606
+ return
607
+ global SHARD_DIR_REBUILDS
608
+ SHARD_DIR_REBUILDS += 1
609
+ if len(SHARD_DIR_REBUILD_SAMPLES) < _SHARD_DIR_SAMPLE_CAP:
610
+ SHARD_DIR_REBUILD_SAMPLES.append((os.path.abspath(directory), where))
611
+ try:
612
+ sys.stderr.write(
613
+ "[fsutil] ShardedLog 分片目录缺失,已重建(目录 %s,调用点 %s)——"
614
+ "本进程持有的分片实例不自愈;本次后进程内累计重建 %d 次。"
615
+ "排查方向:库根 _index_log/ 被外部清理/还原删掉,或库根被换过;"
616
+ "正文 .md 未受影响,索引缺口由重放/全库扫描补齐。\n"
617
+ % (os.path.basename(os.path.abspath(directory)), where,
618
+ SHARD_DIR_REBUILDS))
619
+ except Exception: # noqa: BLE001 —— 告警面不反向破坏写路径
620
+ pass
621
+
622
+
623
+ # 生效条件:无条件把 SHARD_DIR_HEAL_FAILURES 累加 1、按 _SHARD_DIR_SAMPLE_CAP 上限补 (目录绝对路径, where, 异常类型名) 样本,并向 sys.stderr 写一行含失败原因类型与 hint 指向的告警;exc 为 None 时原因类型按 "NoneType" 渲染;stderr 写失败被吞掉(计数与样本不受影响),本函数不抛异常(真正的失败由调用方抛 ShardDirError)。
624
+ def note_shard_dir_heal_failure(directory, where, exc=None):
625
+ """登记一次「目录缺失且重建失败」+ stderr 告警(B4 可观测面)。"""
626
+ global SHARD_DIR_HEAL_FAILURES
627
+ SHARD_DIR_HEAL_FAILURES += 1
628
+ if len(SHARD_DIR_HEAL_FAILURE_SAMPLES) < _SHARD_DIR_SAMPLE_CAP:
629
+ SHARD_DIR_HEAL_FAILURE_SAMPLES.append(
630
+ (os.path.abspath(directory), where,
631
+ type(exc).__name__ if exc is not None else "NoneType"))
632
+ try:
633
+ sys.stderr.write(
634
+ "[fsutil] ShardedLog 分片目录缺失且**重建失败**(目录 %s,调用点 %s,"
635
+ "原因 %s: %s)——抛 %s(code=%s),不降级为裸 FileNotFoundError;"
636
+ "本次后进程内累计失败 %d 次。\n"
637
+ % (os.path.basename(os.path.abspath(directory)), where,
638
+ type(exc).__name__ if exc is not None else "NoneType", exc,
639
+ ShardDirError.__name__, SHARD_DIR_ERR_CODE,
640
+ SHARD_DIR_HEAL_FAILURES))
641
+ except Exception: # noqa: BLE001 —— 告警面不反向破坏写路径
642
+ pass
643
+
644
+
645
+ # 生效条件:无入参,返回四元组 (重建累计次数, 重建样本元组, 失败累计次数, 失败样本元组)——样本元素分别为 (目录绝对路径, 调用点标签) 与 (目录绝对路径, 调用点标签, 异常类型名);元组为副本,调用方改动不影响记账面。
646
+ def shard_dir_stats():
647
+ """读分片目录自愈记账(只读):(重建数, 重建样本, 失败数, 失败样本)。"""
648
+ return (SHARD_DIR_REBUILDS, tuple(SHARD_DIR_REBUILD_SAMPLES),
649
+ SHARD_DIR_HEAL_FAILURES, tuple(SHARD_DIR_HEAL_FAILURE_SAMPLES))
650
+
651
+
652
+ # 生效条件:无入参、无返回值;把 SHARD_DIR_REBUILDS / SHARD_DIR_HEAL_FAILURES 归零并清空两个样本列表(守卫与运维读面前的重置点),只动本进程计数面、不触盘面。
653
+ def reset_shard_dir_stats():
654
+ """清零分片目录自愈记账(守卫/运维用;只影响本进程计数面)。"""
655
+ global SHARD_DIR_REBUILDS, SHARD_DIR_HEAL_FAILURES
656
+ SHARD_DIR_REBUILDS = 0
657
+ SHARD_DIR_HEAL_FAILURES = 0
658
+ del SHARD_DIR_REBUILD_SAMPLES[:]
659
+ del SHARD_DIR_HEAL_FAILURE_SAMPLES[:]
660
+
661
+
662
+ # 生效条件:directory 为分片目录路径时,os.path.isdir(directory) 为真立即返回 False(零动作、零记账);为假则 os.makedirs(directory, exist_ok=True)——成功时经 note_shard_dir_rebuild 按 where 记账("init" 不记)+ stderr 告警并返回 True;makedirs 抛 OSError(权限不足 / 有文件占着该路径 / 路径不可达)时先 note_shard_dir_heal_failure 记账 + 告警,再抛 ShardDirError(code=SHARD_DIR_ERR_CODE、message 首字段为 [code]、hint 含目录与三步处置、path=directory),cause 链(raise ... from)保留原异常;本函数是分片目录存在性的唯一实现点。
663
+ def ensure_shard_dir(directory: str, where: str = "append") -> bool:
664
+ """`ShardedLog` 分片目录的**唯一**存在性保证点(B4)。
665
+
666
+ 返回 True 表示本次**建了目录**(缺失→重建),False 表示目录本来就在。
667
+ where 只影响记账口径("init" = `ShardedLog.__init__` 首建,正常路径不记;
668
+ "append" = 打开分片前的病态自愈面,记账 + 告警)。
669
+ """
670
+ if os.path.isdir(directory):
671
+ return False
672
+ try:
673
+ os.makedirs(directory, exist_ok=True)
674
+ except OSError as exc:
675
+ note_shard_dir_heal_failure(directory, where, exc)
676
+ raise ShardDirError(
677
+ "[%s] 分片目录不存在且重建失败:%s(底层 %s: %s)"
678
+ % (SHARD_DIR_ERR_CODE, directory, type(exc).__name__, exc),
679
+ code=SHARD_DIR_ERR_CODE,
680
+ hint=("分片目录 %s 不存在且无法重建(父目录不可写 / 权限不足 / 有文件"
681
+ "占着该路径)。处置:①确认库根可写(Windows:去掉只读属性或"
682
+ "改用有写权限的账号;POSIX:chmod/所有权);②查是否有进程或"
683
+ "杀毒软件把该目录删掉或占住;③索引日志是派生面——正文 .md 未"
684
+ "受影响,修好权限后重开进程即可由重放 / 全库扫描恢复索引,"
685
+ "无需重写节点。" % directory),
686
+ path=directory) from exc
687
+ note_shard_dir_rebuild(directory, where)
688
+ return True
689
+
690
+
691
+ # 生效条件:directory 经 abspath 存入 self.dir 后经 ensure_shard_dir(self.dir, "init") 保证目录存在(首建不记账;失败抛 ShardDirError 而非裸 OSError),实例分片文件名由 os.getpid() 与 uuid.uuid4().hex[:8] 拼成 "{pid}-{hex8}.log",append 时各写者只写自己这一分片,从而不共享写入点。
318
692
  class ShardedLog:
319
693
  """每写者独占一个分片的 append-only 日志——不能丢记录时用它。
320
694
 
@@ -323,20 +697,31 @@ class ShardedLog:
323
697
  代价是读取要合并 N 个分片,靠记录里的单调序号 (t, seq) 恢复全局写入顺序。
324
698
  """
325
699
 
326
- # 生效条件:directory 经 abspath 存入 self.dir 并 makedirs(exist_ok=True),self.path 为 self.dir 下 "{os.getpid()}-{uuid.uuid4().hex[:8]}.log",并置 self._seq = 0、self._fh = None。
700
+ # 生效条件:directory 经 abspath 存入 self.dir 并经 ensure_shard_dir(self.dir, "init") 保证存在(唯一实现点;首建不记账,失败抛 ShardDirError),self.path 为 self.dir 下 "{os.getpid()}-{uuid.uuid4().hex[:8]}.log",并置 self._seq = 0、self._fh = None。
327
701
  def __init__(self, directory: str):
328
702
  self.dir = os.path.abspath(directory)
329
- os.makedirs(self.dir, exist_ok=True)
703
+ # B4:建目录单点收口到 ensure_shard_dir(不再各写各的 makedirs)——
704
+ # where="init" 是首建(正常路径,不记账);重建失败的记账 + 告警与
705
+ # 结构化错误由该单点负责(原来这里是裸 OSError 直冒调用方)。
706
+ ensure_shard_dir(self.dir, "init")
330
707
  self.path = os.path.join(
331
708
  self.dir, f"{os.getpid()}-{uuid.uuid4().hex[:8]}.log")
332
709
  self._seq = 0
333
710
  self._fh = None
334
711
 
335
- # 生效条件:self._seq 先自增 1,record 被 dict(record, _t=time.time(), _s=self._seq) 复制;self._fh 为 None 时以 "a"、encoding="utf-8"、newline="\n" 打开 self.path,随后写入 json.dumps(ensure_ascii=False, separators=(",", ":")) + "\n" 并 flush。
712
+ # 生效条件:self._seq 先自增 1,record 被 dict(record, _t=time.time(), _s=self._seq) 复制;self._fh 为 None 时先经 ensure_shard_dir(self.dir, "append") 复查并(缺失即)重建分片目录——重建成功记账 + stderr 告警、失败抛 ShardDirError——再以 "a"、encoding="utf-8"、newline="\n" 打开 self.path(路径照常,不换分片名),随后写入 json.dumps(ensure_ascii=False, separators=(",", ":")) + "\n" 并 flush。
336
713
  def append(self, record: dict):
337
714
  self._seq += 1
338
715
  record = dict(record, _t=time.time(), _s=self._seq)
339
716
  if self._fh is None:
717
+ # B4(2026-09-30):**每次重开分片前**复查目录。病灶:目录在进程
718
+ # 存活期间被删后,原实现在这里 `open` 抛 FileNotFoundError 且此后
719
+ # 永不恢复(实测三连抛、目录三次都不重建、close 抛同异常)。缺失
720
+ # 即重建,路径照常仍是 self.path(同一分片名,只补回目录这一层)。
721
+ # 边界(诚实声明):本条只在**句柄为 None 的重开点**复查——已持有
722
+ # 打开句柄期间目录被删不在本守卫面内(Windows 上被打开的文件无法
723
+ # 删除,生产写点 `MdCG.flush` 每批写完即 close ⇒ 必经此重开点)。
724
+ ensure_shard_dir(self.dir, "append")
340
725
  self._fh = open(self.path, "a", encoding="utf-8", newline="\n")
341
726
  self._fh.write(json.dumps(record, ensure_ascii=False,
342
727
  separators=(",", ":")) + "\n")
@@ -349,17 +734,78 @@ class ShardedLog:
349
734
  self._fh = None
350
735
 
351
736
  @staticmethod
352
- # 生效条件:directory 是目录时,按 sorted(os.listdir(directory)) 顺序对每个以 ".log" 结尾的文件调用 read_jsonl 汇总记录,再按每条记录 r.get("_t", 0)、r.get("_s", 0)(缺键取 0)排序后返回全部记录;directory 不是目录时直接返回 []。
737
+ # 生效条件:directory 是目录时,按 sorted(os.listdir(directory)) 顺序对每个以 ".log" 结尾的文件读取汇总(单分片 PermissionError 时以 5ms×8 短重试等 Windows delete-pending 窗口过去、窗口后 FileNotFoundError 视为已被 compact 并入快照清走而跳过、重试耗尽照常 raise),逐行只收 dict 记录——非对象行(null/[]/123/"abc" 等合法 JSON)跳过并经 note_nonobject_rows 记账 + stderr 告警(不得静默);随后按排序键 (_norm(r.get("_t", 0)), _norm(r.get("_s", 0))) 排序,其中 _norm 只把「数值(int/float,含 bool)且非 NaN」原样透传、其余槽(str/list/dict/None/NaN)一律记 0——坏型槽经 note_bad_sortkey_rows 记账 + stderr 告警(不得静默),记录本身不丢;合法记录(_t/_s 均数值,缺键按默认值 0)的排序键逐位等于旧键 (r.get("_t", 0), r.get("_s", 0)),故其相对次序与改前逐位一致;directory 不是目录时直接返回 []。
353
738
  def read_all(directory: str):
354
- """按全局写入顺序回放所有分片。"""
739
+ """按全局写入顺序回放所有分片。
740
+
741
+ N170(2026-09-27):并发 compact 的 delete-pending 窗口容忍。他进程
742
+ close→compact_index 的 ShardedLog.clear(mdcg.py:1137,锁内「先并快照
743
+ 再删分片」)与本读方的 listdir→open 交错时,Windows 上 open 命中
744
+ 「已 remove、名未消」的 delete-pending 态 → PermissionError [Errno 13]
745
+ (test_review_conformance【9】4 decide worker 同根并发实测复现:
746
+ worker 在 MdCGOS.__init__ 崩溃、stdout 空,父进程 json.loads("")
747
+ 二次崩成 JSONDecodeError)。短重试等窗口过去:窗口过后文件要么可读、
748
+ 要么已真删。已真删(FileNotFoundError)跳过是**安全**的——clear 的
749
+ 契约是分片记录先并入快照再删(mdcg.py:1136-1137 顺序),本读方的
750
+ 快照基底的陈旧读界与既有「他进程未 flush 写入不可见」边界同格,
751
+ 由 _index_signature 指纹机制在下次重载收敛;重试耗尽的 PermissionError
752
+ 照常上抛,真权限问题不掩盖。
753
+
754
+ N225(2026-09-29):**非对象行容忍 + 可观测**。`read_jsonl` 按 JSON
755
+ 合法性收行,`null` / `[]` / `123` / `"abc"` 都会原样产出;直接进
756
+ `recs` 会让下面的 `r.get("_t", 0)` 抛 AttributeError(实测:MdCG
757
+ 构造 + compact_index 双崩)。此处逐行分流:dict 进回放列表,非对象行
758
+ 进记账面(note_nonobject_rows → 模块级计数 + 样本 + stderr 告警)。
759
+ 分流必须在本层做——`read_jsonl` 的通用契约(其它消费面)不在本次
760
+ 范围,不得改。整片读成功才记账:半途重试不重复计数。
761
+
762
+ N225 补强(2026-09-29,**排序键健壮化**):行是 dict 不等于键可排序
763
+ ——`_t` / `_s` 被外部写成 `"abc"` / `[]` / `null` 时,旧键
764
+ `(r.get("_t", 0), r.get("_s", 0))` 在 sort 里抛 TypeError
765
+ (`'<' not supported between instances of 'str' and 'float'`),
766
+ 而 except 面只有 ValueError/OSError ⇒ 与 N225 原始缺陷同一条断链,
767
+ 只是深度 2(N225 v1.0 报告 §0 已点名的缺口)。现按**单槽归一**处理:
768
+ 数值(int/float,含 bool)原样,其余槽记 0——与「缺键取 0」同一口径,
769
+ 不新造第二套默认值。归一化真的发生时(归一结果 ≠ 原值对)进
770
+ note_bad_sortkey_rows 记账(**容忍 ≠ 静默**,记录不丢)。
771
+ 次序不变性:合法记录的排序键逐位等于旧键 ⇒ 同一稳定排序算法在同一
772
+ 输入序列上产出同一次序,故相对次序与改前逐位一致(守卫 F2 用真实
773
+ 分片记录与旧键 oracle 逐条对照)。
774
+ """
355
775
  if not os.path.isdir(directory):
356
776
  return []
777
+
778
+ # 生效条件:v 为 int/float(含 bool)且 v == v(非 NaN)时原样返回 v,其余取值(str/list/dict/None/NaN)一律返回 0——排序键单槽归一,NaN 必须排除(`nan == nan` 为假、与任何数比较恒假:它不抛但让次序不确定)。
779
+ def _norm(v):
780
+ return v if (isinstance(v, (int, float)) and v == v) else 0
781
+
357
782
  recs = []
358
783
  for fn in sorted(os.listdir(directory)):
359
784
  if not fn.endswith(".log"):
360
785
  continue
361
- recs.extend(read_jsonl(os.path.join(directory, fn)))
362
- recs.sort(key=lambda r: (r.get("_t", 0), r.get("_s", 0)))
786
+ p = os.path.join(directory, fn)
787
+ for _attempt in range(8):
788
+ try:
789
+ batch, skips, badkeys = [], [], []
790
+ for lineno, rec in enumerate(read_jsonl(p), 1):
791
+ if not isinstance(rec, dict):
792
+ skips.append((p, lineno, rec))
793
+ continue
794
+ batch.append(rec)
795
+ raw = (rec.get("_t", 0), rec.get("_s", 0))
796
+ if (_norm(raw[0]), _norm(raw[1])) != raw:
797
+ badkeys.append((p, lineno, raw))
798
+ recs.extend(batch)
799
+ note_nonobject_rows(skips)
800
+ note_bad_sortkey_rows(badkeys)
801
+ break
802
+ except FileNotFoundError:
803
+ break # 已被 compact 清走(记录已并入快照)
804
+ except PermissionError:
805
+ if _attempt == 7:
806
+ raise
807
+ time.sleep(0.005)
808
+ recs.sort(key=lambda r: (_norm(r.get("_t", 0)), _norm(r.get("_s", 0))))
363
809
  return recs
364
810
 
365
811
  @staticmethod
package/md_cg/identity.py CHANGED
@@ -277,7 +277,7 @@ def infer_position(cg, subject_id):
277
277
  tag = f"subject:{subject_id}"
278
278
  nodes = cg.index.get("nodes") or {}
279
279
  votes, n = {}, 0
280
- for nid, e in nodes.items():
280
+ for nid, e in list(nodes.items()):
281
281
  tg = set(e.get("tags") or [])
282
282
  if tag not in tg or (tg & {TAG_ANCHOR, TAG_TRAIT}):
283
283
  continue
@@ -313,7 +313,7 @@ def profile(cg, subject_id):
313
313
  tag = f"subject:{subject_id}"
314
314
  nodes = cg.index.get("nodes") or {}
315
315
  anchors, traits = [], []
316
- for nid, e in nodes.items():
316
+ for nid, e in list(nodes.items()):
317
317
  tg = set(e.get("tags") or [])
318
318
  if tag not in tg:
319
319
  continue
@@ -342,7 +342,7 @@ def positions(cg, limit=0):
342
342
  """所有主体的位置效应分布(OS 视角:谁在记录/反思/验证/输出/维生)。"""
343
343
  nodes = cg.index.get("nodes") or {}
344
344
  counts = {}
345
- for _nid_, e in nodes.items():
345
+ for _nid_, e in list(nodes.items()):
346
346
  for t in (e.get("tags") or []):
347
347
  if isinstance(t, str) and t.startswith("subject:"):
348
348
  sid = t[len("subject:"):]
package/md_cg/insight.py CHANGED
@@ -29,6 +29,7 @@ import math
29
29
  import os
30
30
  import time
31
31
 
32
+ from . import protect
32
33
  from .fsutil import append_jsonl
33
34
 
34
35
  INSIGHT_LOG = "_insight.jsonl"
@@ -78,7 +79,7 @@ def _append(cg, rec):
78
79
  def _events(cg):
79
80
  """全部洞见事件节点 id(按 index 层标签粗筛,避免全量读盘)。"""
80
81
  nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
81
- return [nid for nid, e in nodes.items()
82
+ return [nid for nid, e in list(nodes.items())
82
83
  if TAG_EVENT in ((e or {}).get("tags") or [])]
83
84
 
84
85
 
@@ -232,7 +233,7 @@ def _normalize_evidence(evidence=None, v_types=None):
232
233
  return out
233
234
 
234
235
 
235
- # 生效条件:_read_event(cg,node_id) 取不到事件节点、或 verdict 经 str(verdict or "").strip().lower() 后非空且不是 verified/falsified 时抛 ValueError;verdict 为 None 或空白时按 v3 → v2 → v1 条数 ≥ V1_MIN_EVIDENCE 的顺序定 verified,有证据但不达门槛或无证据则保持 STATE_PENDING 并附 reason;显式 verdict 直接采信,verified 分支重要度保底 IMPORTANCE_FLOOR 并置保护位,falsified 分支打 TAG_FALSIFIED。
236
+ # 生效条件:_read_event(cg,node_id) 取不到事件节点、或 verdict 经 str(verdict or "").strip().lower() 后非空且不是 verified/falsified 时抛 ValueError;verdict 为 None 或空白时按 v3 → v2 → v1 条数 ≥ V1_MIN_EVIDENCE 的顺序定 verified,有证据但不达门槛或无证据则保持 STATE_PENDING 并附 reason;显式 verdict 直接采信,verified 分支重要度保底 IMPORTANCE_FLOOR 并置保护位,falsified 分支打 TAG_FALSIFIED;裁决分支落盘前经 protect.guard_overwrite(层闸 + 保护闸),写盘后同步内存索引条目并**标脏**(cg._dirty[node_id]=ent,推进读缓存代际与索引增量日志)。
236
237
  def verify(cg, node_id=None, evidence=None, v_types=None, verdict=None,
237
238
  actor=None, note=""):
238
239
  """用 V1/V2/V3 外部证据裁决洞见事件。
@@ -243,6 +244,9 @@ def verify(cg, node_id=None, evidence=None, v_types=None, verdict=None,
243
244
  · V1 可检索/被引用 需 **≥ V1_MIN_EVIDENCE 条** 才成立;
244
245
  · 其余(无证据 / V1 不足)→ **保持 pending 并给出原因**,不判定。
245
246
  verified 后:重要度保底 0.9(``IMPORTANCE_FLOOR``)并置保护位。
247
+ 裁决分支落盘前过 ``protect.guard_overwrite``(层闸 + 保护闸),写盘后同步
248
+ 内存索引条目并**标脏**(``cg._dirty[node_id]``,C-1)——否则读缓存默认开时
249
+ 同进程「verify 后读」拿旧 insight_state/tags/importance。
246
250
  """
247
251
  node = _read_event(cg, node_id)
248
252
  if not node:
@@ -294,6 +298,14 @@ def verify(cg, node_id=None, evidence=None, v_types=None, verdict=None,
294
298
  else:
295
299
  tg.append(TAG_FALSIFIED)
296
300
  fm["tags"] = tg
301
+ # N197(2026-09-28):本面是**既有节点的覆写**——原先直调 `cg._write_node`,
302
+ # 既无 `principal.require_layer_write` 亦无 `protect.guard_write`,与同一身份
303
+ # 对同层 `add` 的待遇相反(add 被 AccessDenied,这里照样落盘):越层腿
304
+ # (sustain layers=('self',) verify 他人的 contextual 事件)+ 保护腿
305
+ # (self/anchor/immutable 节点被无痕覆写、无快照无审计)都由这一处补闸收口。
306
+ # 层与敏感度取**本节点已读到的 frontmatter 真值**(不靠索引缺字段时的回落)。
307
+ protect.guard_overwrite(cg, node_id, layer=fm.get("layer"),
308
+ sensitivity=fm.get("sensitivity"), actor=actor)
297
309
  cg._write_node(node_id, os.path.join(cg.root, node["path"]), fm,
298
310
  node.get("content") or "")
299
311
  ent = ((getattr(cg, "index", None) or {}).get("nodes") or {}).get(node_id)
@@ -301,6 +313,15 @@ def verify(cg, node_id=None, evidence=None, v_types=None, verdict=None,
301
313
  ent["importance"] = fm.get("importance")
302
314
  ent["tags"] = list(tg)
303
315
  ent["insight_state"] = final
316
+ # 标脏(N133 修复,对照先例 md_cg/mdcg.py 的 update_tags / verify 直写
317
+ # 分支 `self._dirty[node_id] = e`):只改内存 entry 时读缓存(默认开)
318
+ # 的 path_gen 不推进 ⇒ `_fresh` 继续判旧 fm 新鲜,同进程「verify 后读」
319
+ # 拿到旧 insight_state/tags/importance;标脏同时让本写进
320
+ # `_dirty → flush → _index_log` 重放。**不下沉进 `_write_node`**(该口
321
+ # 另有「只对账索引不落盘」的调用方,下沉会凭空产生写入代际与自重载)。
322
+ _dirty = getattr(cg, "_dirty", None)
323
+ if isinstance(_dirty, dict):
324
+ _dirty[node_id] = ent
304
325
  _append(cg, {"action": "verify", "node_id": node_id, "state": final,
305
326
  "decided": True, "reason": reason or "显式裁决",
306
327
  "evidence": ev, "importance": fm.get("importance"),
@@ -417,7 +438,7 @@ def outlook(cg, window_days=None, sample_limit=8, recent_days=7, now=None):
417
438
  imps, protected, no_neg, recent = [], 0, 0, 0
418
439
  lo_recent = now - float(recent_days) * 86400.0
419
440
  hi_imp_unprotected = []
420
- for nid, e in nodes.items():
441
+ for nid, e in list(nodes.items()):
421
442
  e = e or {}
422
443
  layers[_layer_of(e)] = layers.get(_layer_of(e), 0) + 1
423
444
  b = str(e.get("verification_basis") or "unset")
package/md_cg/linkref.py CHANGED
@@ -131,7 +131,7 @@ def known_ids(cg):
131
131
  if not callable(guard):
132
132
  return set(nodes) # 非安全库(MdCG)无读隔离概念
133
133
  out = set()
134
- for nid, e in nodes.items():
134
+ for nid, e in list(nodes.items()):
135
135
  try:
136
136
  if guard(e):
137
137
  out.add(nid)