@furongjun1999/dsh-memory 0.5.1 → 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 (187) hide show
  1. package/README.md +588 -552
  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/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
  8. 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
  9. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  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_v18.md +183 -0
  23. 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
  24. 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
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  33. 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
  34. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  35. 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
  36. 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
  37. 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
  38. 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
  39. 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
  40. 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
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.js +24 -2
  43. package/lib/cli.d.ts +3 -0
  44. package/lib/cli.js +66 -0
  45. package/lib/hooks.d.ts +31 -2
  46. package/lib/hooks.js +219 -16
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +302 -0
  49. package/lib/lib/prompt_safety.d.ts +52 -1
  50. package/lib/lib/prompt_safety.js +76 -1
  51. package/md_cg/audit.py +652 -379
  52. package/md_cg/bench6_arms.py +1 -1
  53. package/md_cg/bench_p0.py +1 -1
  54. package/md_cg/branches.py +2 -2
  55. package/md_cg/ccgc.py +1071 -1006
  56. package/md_cg/chain.py +1 -1
  57. package/md_cg/consistency.py +97 -9
  58. package/md_cg/consolidate.py +34 -9
  59. package/md_cg/crypto.py +59 -26
  60. package/md_cg/docindex.py +16 -1
  61. package/md_cg/evidence.py +585 -582
  62. package/md_cg/evolution.py +17 -1
  63. package/md_cg/export.py +1 -1
  64. package/md_cg/forgetting.py +309 -13
  65. package/md_cg/fsutil.py +454 -8
  66. package/md_cg/identity.py +3 -3
  67. package/md_cg/insight.py +24 -3
  68. package/md_cg/linkref.py +1 -1
  69. package/md_cg/logref.py +327 -0
  70. package/md_cg/mcp_server.py +4152 -3818
  71. package/md_cg/mdcg.py +4208 -3462
  72. package/md_cg/mdcos.py +4491 -4137
  73. package/md_cg/mreview/pipeline.py +15 -1
  74. package/md_cg/nodefile.py +639 -575
  75. package/md_cg/protect.py +158 -11
  76. package/md_cg/protocol.py +41 -2
  77. package/md_cg/provenance.py +22 -4
  78. package/md_cg/reach.py +4 -4
  79. package/md_cg/readcache.py +76 -15
  80. package/md_cg/reconcile.py +1 -1
  81. package/md_cg/refindex.py +246 -37
  82. package/md_cg/refine.py +1 -1
  83. package/md_cg/review_cli.py +49 -4
  84. package/md_cg/routing.py +32 -5
  85. package/md_cg/run_tests.py +17 -0
  86. package/md_cg/scrub.py +30 -3
  87. package/md_cg/security.py +47 -1
  88. package/md_cg/self_state.py +5 -5
  89. package/md_cg/sources.py +23 -6
  90. package/md_cg/srcindex.py +352 -0
  91. package/md_cg/stg.py +47 -3
  92. package/md_cg/subgraph.py +2 -2
  93. package/md_cg/sustain.py +1299 -1168
  94. package/md_cg/tasks.py +469 -470
  95. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  96. package/md_cg/test_b1b2_write_face.py +417 -0
  97. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  98. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  99. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  100. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  101. package/md_cg/test_c3_transient_read_negative.py +793 -0
  102. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  103. package/md_cg/test_ccg_form_parity.py +188 -0
  104. package/md_cg/test_ccgc.py +28 -3
  105. package/md_cg/test_govern_directread.py +17 -4
  106. package/md_cg/test_h2_session_view_norm.py +233 -0
  107. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  108. package/md_cg/test_hive_ingest.py +285 -285
  109. package/md_cg/test_index_crossprocess_reload.py +301 -0
  110. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  111. package/md_cg/test_issue43_default_policy.py +865 -0
  112. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  113. package/md_cg/test_linkref.py +10 -3
  114. package/md_cg/test_lock.py +2 -2
  115. package/md_cg/test_logref.py +1109 -0
  116. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  117. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  118. package/md_cg/test_mr_m2.py +15 -3
  119. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  120. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  121. package/md_cg/test_n176_link_trust.py +221 -0
  122. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  123. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  124. package/md_cg/test_n195_writepath_reload.py +283 -0
  125. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  126. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  127. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  128. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  129. package/md_cg/test_n201_proposal_visibility.py +382 -0
  130. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  131. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  132. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  133. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  134. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  135. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  136. package/md_cg/test_n225_nonobject_load.py +1110 -0
  137. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  138. package/md_cg/test_neg_condition_hits.py +333 -0
  139. package/md_cg/test_neg_tail_honesty.py +663 -0
  140. package/md_cg/test_none_id_write_guard.py +166 -0
  141. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  142. package/md_cg/test_p1.py +5 -5
  143. package/md_cg/test_p11_consistency.py +161 -12
  144. package/md_cg/test_p26_refindex.py +2 -2
  145. package/md_cg/test_p27_docindex.py +777 -774
  146. package/md_cg/test_p28_refcheck.py +740 -13
  147. package/md_cg/test_p29_session_ingest_export.py +2 -2
  148. package/md_cg/test_p2_mcp.py +12 -1
  149. package/md_cg/test_p30_maintain.py +33 -2
  150. package/md_cg/test_p31_insight.py +1 -0
  151. package/md_cg/test_p9c_dedup_hints.py +276 -0
  152. package/md_cg/test_policy_required_ccg.py +426 -0
  153. package/md_cg/test_protocol.py +22 -0
  154. package/md_cg/test_rank_parity_score_mode.py +516 -0
  155. package/md_cg/test_read_face_input_gates.py +363 -0
  156. package/md_cg/test_read_face_semantics.py +489 -0
  157. package/md_cg/test_recall_face_guards.py +812 -0
  158. package/md_cg/test_rejected_credential_forms.py +188 -0
  159. package/md_cg/test_rejected_redact.py +146 -0
  160. package/md_cg/test_retr_s1b.py +2 -2
  161. package/md_cg/test_retr_s5.py +20 -7
  162. package/md_cg/test_review_cli_attribution.py +177 -0
  163. package/md_cg/test_review_cli_visibility.py +235 -0
  164. package/md_cg/test_review_conformance.py +21 -3
  165. package/md_cg/test_security_audit_v21.py +2 -2
  166. package/md_cg/test_server_version.py +67 -0
  167. package/md_cg/test_srcindex.py +171 -0
  168. package/md_cg/test_tenant_env_override_warn.py +63 -50
  169. package/md_cg/test_token_lowercase_form.py +315 -0
  170. package/md_cg/test_v21r1_package_version.py +310 -0
  171. package/md_cg/test_writepipe.py +10 -4
  172. package/md_cg/tokens.py +225 -95
  173. package/md_cg/tool_face.py +4 -4
  174. package/md_cg/trust.py +49 -5
  175. package/md_cg/units.py +204 -6
  176. package/md_cg/weights.py +4 -4
  177. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  178. package/md_cg/writelimit.py +42 -8
  179. package/md_cg/writepipe.py +651 -554
  180. package/package.json +8 -2
  181. package/skills/plugin.json +1 -1
  182. package/src/bridge.ts +25 -2
  183. package/src/cli.ts +65 -0
  184. package/src/hooks.ts +229 -16
  185. package/src/init.ts +361 -0
  186. package/src/lib/prompt_safety.ts +88 -1
  187. package/zcode/AGENTS.md +10 -10
package/md_cg/sustain.py CHANGED
@@ -1,1169 +1,1300 @@
1
- # -*- coding: utf-8 -*-
2
- """md_cg · 持续性自维持(记忆 OS #4):常驻 / 心跳 / 自愈 / 会话续接
3
-
4
- 路线图「常驻服务:会话 / 心跳 / 自愈」的落地。回答三个问题:
5
-
6
- ① 别人怎么知道我还活着?—— **心跳戳**
7
- `<net_dir>/heartbeat.<name>.stamp`(原子写,含 ts / pid / uptime / task_running)。
8
- 分级判定对齐 Mutual Sustain Loop v1.1:正常 / 警告 / 失联,阈值按心跳间隔的
9
- 2.5× / 3.5×;**任务执行中阈值放宽**(working factor),避免长任务被误判死亡。
10
- 戳写在仓库外(默认 ~/.mdcg/sustain),不污染知识库,也不随仓库泄露。
11
-
12
- ② 进程被杀会不会留下坏状态?—— **自愈(diagnose → heal)**
13
- 诊断只读、不改动;修复动作幂等且逐条留审计:
14
- · 索引漂移 / 孤儿索引 → 重建索引(索引是派生物,可安全重建)
15
- · 索引增量分片积压 → 合并进快照
16
- · 陈旧临时文件 → 清理(被杀死的写者留下的唯一命名 tmp)
17
- · 日志半截行 → 补换行(否则下一条记录会粘在断行上)
18
- · 私有节点不可解 → **只报告不修**(缺密钥是权限事实,不是故障)
19
- 原则:修复只碰派生物(索引 / 临时文件 / 日志边界),**永不删节点**。
20
-
21
- ③ 重启后从哪继续?—— **会话水位(SessionLedger)**
22
- `<root>/_sessions.json` 记录每个会话的 (last_t, last_seq, events),
23
- 与 `sources.Ingestor` 的 `_sources.json`(源视角水位)互补;重启后
24
- `resume_point(session)` 直接给出续接点,不重复摄取、不丢事件。
25
-
26
- ④ 能力不会被饿死?—— **演化巡检(evolve)**
27
- 自我演化的三类动作(固化 `consolidate` / 重算重要性 `weights` / 去污染 `scrub`)
28
- 早已具备,缺的是**驱动源**:没人周期性问「现在有多少该固化 / 该重算的候选」。
29
- `evolution_candidates()` 以**索引快照**为口径做只读盘点(零读节点文件、零写盘、
30
- 确定性),并挂到常驻循环的 `_tick_evolve()` 上。纪律与 self-heal 一致且更严:
31
- · 巡检恒只读,`auto_evolve=False`(默认)时**只记账不动库**;
32
- · 自愈只放行**确定性且可回滚**的动作(重要性重算,有 rollback);
33
- · 依赖 LLM 的固化**永不自动跑**——巡检报出候选数,交人工另批。
34
-
35
- ⑤ 演进血缘有没有断?—— **派生溯源巡检(G8)**
36
- 新增节点在建链时把 `derived_from` 写进 frontmatter 并追加到 `<root>/_link.jsonl`。
37
- 可台账会丢、节点会被删,于是血缘会出现**悬空边**(子/父节点已不在库里)。
38
- `diagnose()` 每次都做只读盘点(零读节点文件:索引已带 `derived_from`),把悬空边
39
- 报为 `provenance_dangling`(severity=info、**无自动修复**)——关系事实的去留由人
40
- 处置,不给「自动删边」这种会篡改历史的动作。
41
-
42
- ⑥ 对端挂了谁来救?—— **互维闭环(P-T-110 最小投影 · #30)**
43
- 两个灵枢互为维生系统:`mutual_watch` 读对端心跳 → 失联则**幂等拉起**
44
- (pid 探活防误判 + 冷却防风暴)→ **验戳新鲜闭合**:拉起后必须轮询到
45
- 对端戳变新才算救活,否则如实报 `mutual_peer_unresponsive`——这正是
46
- mutual-sustain-loop v1.1 §7 的 W4 部署教训(「拉起后应验证对端戳新鲜度,
47
- 当时缺该校验」)的机制化:不假装成功。`mutual_status` 做双亡检测:
48
- 自己也失联时互维本身不可信 → 显式上报外部告警语义,绝不静默。
49
-
50
- 零第三方依赖。
51
- """
52
- from __future__ import annotations
53
-
54
- import json
55
- import os
56
- import threading
57
- import time
58
-
59
- from . import crypto
60
- from .datapath import aux_root
61
- from .fsutil import append_jsonl, atomic_write, ends_mid_line
62
- from .mdcg import LAYERS
63
-
64
- STAMP_VERSION = 1
65
- SUSTAIN_LOG = "_sustain.jsonl"
66
- LEDGER_FILE = "_sessions.json"
67
-
68
- DEFAULT_BEAT_INTERVAL = 600.0 # 心跳间隔 10min(对齐 mutual-sustain-loop)
69
- DEFAULT_HEAL_INTERVAL = 300.0 # 自愈巡检 5min
70
- DEFAULT_SCRUB_INTERVAL = 3600.0 # 记忆自净(抽查/去污染/校准)1h
71
- DEFAULT_EVOLVE_INTERVAL = 7200.0 # 演化巡检(固化/重要性候选盘点)2h;只读
72
- DEFAULT_TIDY_INTERVAL = 21600.0 # 整理巡检(contextual 同构组聚合)6h
73
- DEFAULT_WARN_FACTOR = 2.5 # 2.5× 心跳间隔 → 警告
74
- DEFAULT_DEAD_FACTOR = 3.5 # 3.5× → 失联
75
- DEFAULT_WORKING_FACTOR = 2.0 # 任务执行中阈值 ×2
76
- STALE_TEMP_AGE = 3600.0 # 临时文件超过 1h 视为陈旧
77
- ACCESS_LOG_COMPACT_LINES = 500 # 访问日志超过该行数即折叠(否则无上限增长)
78
- _POLL = 0.2 # 循环轮询步长(常驻进程 CPU 可忽略)
79
-
80
-
81
- # --------------------------------------------------------------------------
82
- # 心跳
83
- # --------------------------------------------------------------------------
84
-
85
- # 生效条件:d 为真值时返回 d,d 为 None/空串时回落 MDCG_SUSTAIN_DIR,该环境变量也未设或为空串时返回 os.path.join(aux_root(), "sustain")(默认 ~/.mdcg/sustain,可经 MDCG_AUX_ROOT 改);
86
- def net_dir(d: str = None) -> str:
87
- """心跳戳目录:显式 → MDCG_SUSTAIN_DIR → aux_root()/sustain(仓库外)。"""
88
- return (d or os.environ.get("MDCG_SUSTAIN_DIR")
89
- or os.path.join(aux_root(), "sustain"))
90
-
91
-
92
- # 生效条件:给定必填 name,返回 net_dir(d) 下 heartbeat.<name>.stamp 的拼接路径。
93
- def stamp_path(name: str, d: str = None) -> str:
94
- return os.path.join(net_dir(d), f"heartbeat.{name}.stamp")
95
-
96
-
97
- # 生效条件:name 必填,组装含模块常量 STAMP_VERSION 与 task_running 的戳记录、原子写入 stamp_path(name,d) 后返回该 rec。
98
- def write_stamp(name: str, d: str = None, **extra) -> dict:
99
- """写一次心跳(原子替换)。extra 会并入戳内容(None 值丢弃)。"""
100
- rec = {"v": STAMP_VERSION, "name": name, "ts": time.time(),
101
- "pid": os.getpid(),
102
- "iso": time.strftime("%Y-%m-%dT%H:%M:%S"),
103
- "task_running": bool(extra.pop("task_running", False))}
104
- for k, v in extra.items():
105
- if v is not None:
106
- rec[k] = v
107
- atomic_write(stamp_path(name, d),
108
- json.dumps(rec, ensure_ascii=False, indent=1))
109
- return rec
110
-
111
-
112
- # 生效条件:stamp_path(name, d) 所得路径上 os.path.exists 为真且 json.load 结果为 dict 时,返回该 rec 并附加 age=max(0.0, time.time() - float(rec.get("ts") or 0));该路径不可读、json.load 抛 ValueError/OSError 或 rec 非 dict 时返回 None;
113
- def read_stamp(name: str, d: str = None):
114
- """读心跳戳并附 age(秒);不存在 / 损坏 → None。"""
115
- p = stamp_path(name, d)
116
- if not os.path.exists(p):
117
- return None
118
- try:
119
- with open(p, encoding="utf-8") as f:
120
- rec = json.load(f)
121
- except (ValueError, OSError):
122
- return None
123
- if not isinstance(rec, dict):
124
- return None
125
- rec["age"] = max(0.0, time.time() - float(rec.get("ts") or 0))
126
- return rec
127
-
128
-
129
- # 生效条件:对 stamp_path(name,d) 执行 os.remove,OSError 被静默忽略,无返回值。
130
- def clear_stamp(name: str, d: str = None):
131
- try:
132
- os.remove(stamp_path(name, d))
133
- except OSError:
134
- pass
135
-
136
-
137
- # 生效条件:age 为 None 返回 'absent';否则以 factor=working if task_running else 1.0 比较 age 与 interval*warn*factor、interval*dead*factor,分别返回 'ok'/'warning'/'dead'。
138
- def judge(age, *, interval: float = DEFAULT_BEAT_INTERVAL,
139
- task_running: bool = False, warn: float = DEFAULT_WARN_FACTOR,
140
- dead: float = DEFAULT_DEAD_FACTOR,
141
- working: float = DEFAULT_WORKING_FACTOR) -> str:
142
- """按心跳年龄分级:ok / warning / dead / absent。
143
-
144
- task_running=True 时阈值整体放宽 working 倍——长任务期间不写心跳是正常的,
145
- 若不放宽会把「正在干活」误判成「已死」。
146
- """
147
- if age is None:
148
- return "absent"
149
- factor = working if task_running else 1.0
150
- if age <= interval * warn * factor:
151
- return "ok"
152
- if age <= interval * dead * factor:
153
- return "warning"
154
- return "dead"
155
-
156
-
157
- # 生效条件:net_dir(d) 可列为目录时遍历 heartbeat.*.stamp 并返回含 age 与 state 的记录列表,否则返回 []。
158
- def peers(d: str = None):
159
- """列出本机所有心跳戳(含状态)。"""
160
- base = net_dir(d)
161
- if not os.path.isdir(base):
162
- return []
163
- out = []
164
- for fn in sorted(os.listdir(base)):
165
- if not (fn.startswith("heartbeat.") and fn.endswith(".stamp")):
166
- continue
167
- rec = read_stamp(fn[len("heartbeat."):-len(".stamp")], base)
168
- if rec:
169
- rec["state"] = judge(rec["age"],
170
- task_running=bool(rec.get("task_running")))
171
- out.append(rec)
172
- return out
173
-
174
-
175
- # --------------------------------------------------------------------------
176
- # 互维闭环(P-T-110 最小投影 · #30)
177
- # --------------------------------------------------------------------------
178
-
179
- DEFAULT_RESTART_COOLDOWN = 300.0 # 同一对端两次拉起的最小间隔(防风暴)
180
- DEFAULT_FRESH_TIMEOUT = 60.0 # 拉起后等待对端戳变新鲜的窗口
181
-
182
-
183
- # 生效条件:pid 可转 int 且 >0 时按探活(os.name=='nt' 用 OpenProcess+WaitForSingleObject,否则 os.kill(pid,0))返回 True,转 int 失败或 pid<=0 或探活失败返回 False。
184
- def pid_alive(pid) -> bool:
185
- """进程探活(零第三方依赖):Windows=OpenProcess+WaitForSingleObject;
186
- posix=os.kill(pid,0)。pid 无效 / 已退出 / 权限外 → False(诚实)。"""
187
- try:
188
- pid = int(pid)
189
- except (TypeError, ValueError):
190
- return False
191
- if pid <= 0:
192
- return False
193
- if os.name == "nt":
194
- import ctypes
195
- SYNCHRONIZE = 0x00100000
196
- WAIT_TIMEOUT = 0x00000102
197
- k32 = ctypes.windll.kernel32
198
- h = k32.OpenProcess(SYNCHRONIZE, False, pid)
199
- if not h:
200
- return False
201
- try:
202
- return k32.WaitForSingleObject(h, 0) == WAIT_TIMEOUT
203
- finally:
204
- k32.CloseHandle(h)
205
- try:
206
- os.kill(pid, 0)
207
- return True
208
- except OSError:
209
- return False
210
-
211
-
212
- # 生效条件:cmd 为 argv 列表时以 stdout/stderr=DEVNULL、不经 shell 的 subprocess.Popen 拉起(nt 附加 DETACHED|NEW_GROUP creationflags),返回该 Popen 对象。
213
- def _default_spawner(cmd):
214
- """分离式拉起:argv 列表、不经 shell、输出弃置(Windows 完全脱离父控制台)。"""
215
- import subprocess
216
- kw = {}
217
- if os.name == "nt":
218
- kw["creationflags"] = (0x00000008 | 0x00000200) # DETACHED|NEW_GROUP
219
- return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
220
- stderr=subprocess.DEVNULL, **kw)
221
-
222
-
223
- # 生效条件:root 与 peer 必填,扫描 root/SUSTAIN_LOG(模块常量)返回 op='mutual'、action='restart' 且 detail 以 peer 开头的记录中最大 t,无匹配返回 0.0。
224
- def _last_mutual_restart(root: str, peer: str) -> float:
225
- """_sustain.jsonl 里该对端最近一次互维拉起时间(无 → 0.0,审计即状态)。"""
226
- last = 0.0
227
- try:
228
- with open(os.path.join(root, SUSTAIN_LOG), encoding="utf-8") as f:
229
- for line in f:
230
- try:
231
- r = json.loads(line)
232
- except ValueError:
233
- continue
234
- if (r.get("op") == "mutual" and r.get("action") == "restart"
235
- and str(r.get("detail") or "").startswith(str(peer))):
236
- last = max(last, float(r.get("t") or 0.0))
237
- except OSError:
238
- pass
239
- return last
240
-
241
-
242
- # 生效条件:cg 与 peer 必填,读对端戳判状态:ok/warning 直接返回 action='none';dead/absent 时依次按 pid_alive、restart_cooldown、restart_cmd 有无走 alive_but_stale/restart_cooldown/dead_unhandled/拉起并验戳,返回 out。
243
- def mutual_watch(cg, peer: str, *, restart_cmd=None, d: str = None,
244
- interval: float = DEFAULT_BEAT_INTERVAL,
245
- restart_cooldown: float = DEFAULT_RESTART_COOLDOWN,
246
- fresh_timeout: float = DEFAULT_FRESH_TIMEOUT, poll: float = 0.5,
247
- spawner=None) -> dict:
248
- """互维守护(单侧一次检查):读对端心跳 → 失联则幂等拉起 → 验戳闭合。
249
-
250
- 判定序(P-T-110 语义,全链路审计进 _sustain.jsonl):
251
- ok/warning → 不动作;
252
- dead/absent:
253
- ① 误判防护:戳里 pid 仍存活 → `alive_but_stale`(长任务未标
254
- task_running?)——**不拉起**,不杀活进程;
255
- ② 冷却:距上次拉起 < restart_cooldown → `restart_cooldown`;
256
- ③ 无 restart_cmd → `dead_unhandled`(ok=False,诚实上报没手段,
257
- 不假装能救);
258
- ④ 拉起(spawner 或分离式 Popen)→ **验戳新鲜闭合**(W4 补丁):
259
- 轮询对端戳 ts 超过拉起前值才算 `recovered=True`;超时 →
260
- `recovered=False` + `mutual_peer_unresponsive`——对端拉起后
261
- 互维未激活(v1.1 §7 的 W4 场景)会被显式暴露,不假装成功。
262
- """
263
- rec = read_stamp(peer, d)
264
- state = (judge(rec["age"], interval=interval,
265
- task_running=bool(rec.get("task_running")))
266
- if rec else "absent")
267
- out = {"ok": True, "peer": peer, "state": state, "t": time.time(),
268
- "action": "none", "recovered": None}
269
- if state in ("ok", "warning"):
270
- return out
271
- if rec and pid_alive(rec.get("pid")):
272
- out.update(action="alive_but_stale",
273
- note="对端进程存活但戳陈旧(长任务未标 task_running?)——不拉起")
274
- _audit(cg.root, "mutual", "alive_but_stale", peer)
275
- return out
276
- last = _last_mutual_restart(cg.root, peer)
277
- if last and (time.time() - last) < restart_cooldown:
278
- out.update(action="restart_cooldown",
279
- note="距上次拉起 %.1fs,冷却中" % (time.time() - last))
280
- return out
281
- if not restart_cmd:
282
- out.update(ok=False, action="dead_unhandled",
283
- note="对端失联且未提供 restart_cmd——诚实上报,不假装能救")
284
- _audit(cg.root, "mutual", "dead_unhandled", peer)
285
- return out
286
- before_ts = float((rec or {}).get("ts") or 0.0)
287
- spawn = spawner or _default_spawner
288
- try:
289
- spawn(restart_cmd)
290
- except Exception as e: # noqa: BLE001
291
- out.update(ok=False, action="restart_error",
292
- error="%s: %s" % (type(e).__name__, e))
293
- _audit(cg.root, "mutual", "restart_error", peer)
294
- return out
295
- deadline = time.time() + fresh_timeout
296
- while time.time() < deadline:
297
- r2 = read_stamp(peer, d)
298
- if r2 and float(r2.get("ts") or 0.0) > before_ts:
299
- out.update(action="restarted", recovered=True, fresh_ts=r2.get("ts"))
300
- _audit(cg.root, "mutual", "restart", peer)
301
- return out
302
- time.sleep(poll)
303
- out.update(ok=False, action="restarted", recovered=False,
304
- note=("拉起命令已执行但对端戳未更新(互维未激活?)——"
305
- "mutual_peer_unresponsive,不假装成功"))
306
- _audit(cg.root, "mutual", "restart_unverified", peer)
307
- return out
308
-
309
-
310
- # 生效条件:cg 与 peer 必填,分别按 name 与 peer 读戳判状态,仅当自身与对端状态均属 ('dead','absent') 时返回 ok=False、both_dead=True。
311
- def mutual_status(cg, peer: str, *, d: str = None, name: str = "md_cg",
312
- interval: float = DEFAULT_BEAT_INTERVAL) -> dict:
313
- """互维状态 + 双亡检测:自己也失联时互维不可信 → 显式外部告警语义。"""
314
- mine, theirs = read_stamp(name, d), read_stamp(peer, d)
315
- my_state = (judge(mine["age"], interval=interval,
316
- task_running=bool(mine.get("task_running")))
317
- if mine else "absent")
318
- peer_state = (judge(theirs["age"], interval=interval,
319
- task_running=bool(theirs.get("task_running")))
320
- if theirs else "absent")
321
- dead = ("dead", "absent")
322
- both_dead = my_state in dead and peer_state in dead
323
- return {"ok": not both_dead, "self": my_state, "peer": peer_state,
324
- "both_dead": both_dead,
325
- "note": ("双向同时失联:互维本身已不可信,须外部告警(P-T-110 风险表)"
326
- if both_dead else "互维链路可用")}
327
-
328
-
329
- # --------------------------------------------------------------------------
330
- # 诊断(只读)
331
- # --------------------------------------------------------------------------
332
-
333
- # 生效条件:root 必填,对模块常量 LAYERS 各层 os.walk 统计以 .md 结尾的文件名个数并返回 n(不解析正文)。
334
- def _count_node_files(root: str) -> int:
335
- """轻量统计磁盘节点数(只数文件名,不解析正文)。"""
336
- n = 0
337
- for layer in LAYERS:
338
- for _dp, _dirs, files in os.walk(os.path.join(root, layer)):
339
- n += sum(1 for f in files if f.endswith(".md"))
340
- return n
341
-
342
-
343
- # 生效条件:root 与 older_than 必填,返回 root 及其 LAYERS 各层下以 '.' 开头、名含 '.tmp-' 且 mtime 早于 now-older_than 的路径列表。
344
- def _list_stale_temps(root: str, older_than: float):
345
- now, out = time.time(), []
346
- bases = [root] + [os.path.join(root, l) for l in LAYERS]
347
- for base in bases:
348
- if not os.path.isdir(base):
349
- continue
350
- for name in os.listdir(base):
351
- if ".tmp-" not in name or not name.startswith("."):
352
- continue
353
- p = os.path.join(base, name)
354
- try:
355
- if now - os.path.getmtime(p) > older_than:
356
- out.append(p)
357
- except OSError:
358
- pass
359
- return out
360
-
361
-
362
- # 生效条件:root 必填,返回 root 顶层以 .jsonl/.log 结尾且 ends_mid_line 为真的路径列表;root 非目录返回 []。
363
- def _half_line_logs(root: str):
364
- """顶层行式日志里末尾不是换行的(写者被杀留下的半截记录)。"""
365
- out = []
366
- if not os.path.isdir(root):
367
- return out
368
- for name in sorted(os.listdir(root)):
369
- if not (name.endswith(".jsonl") or name.endswith(".log")):
370
- continue
371
- p = os.path.join(root, name)
372
- if os.path.isfile(p) and ends_mid_line(p):
373
- out.append(p)
374
- return out
375
-
376
-
377
- # 生效条件:root/_index_log 可列为目录时返回其中 .log 结尾的文件名列表,否则返回 []。
378
- def _index_log_shards(root: str):
379
- d = os.path.join(root, "_index_log")
380
- if not os.path.isdir(d):
381
- return []
382
- return [f for f in os.listdir(d) if f.endswith(".log")]
383
-
384
-
385
- # 生效条件:cg 具备 crypto_status 且其返回 unlocked 为假时,返回 cg.index['nodes'] 中 sensitivity 属 crypto.ENCRYPTED_LEVELS 的条目数;属性缺失、调用异常或已解锁时返回 0。
386
- def _locked_nodes(cg) -> int:
387
- """加密引擎在「未解锁」状态下被隔离的私有节点数(缺密钥 = 权限事实)。"""
388
- if not hasattr(cg, "crypto_status"):
389
- return 0
390
- try:
391
- st = cg.crypto_status() or {}
392
- except Exception:
393
- return 0
394
- if st.get("unlocked"):
395
- return 0
396
- nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
397
- return sum(1 for e in nodes.values()
398
- if (e.get("sensitivity") or "") in crypto.ENCRYPTED_LEVELS)
399
-
400
-
401
- # --------------------------------------------------------------------------
402
- # 演化巡检(G7:给「固化 / 重要性」能力装上驱动源)
403
- # --------------------------------------------------------------------------
404
-
405
- #: 演化巡检项 → 对应的写入动作(`scrub` 已有独立 tick,不并入此项)
406
- EVOLVE_FIXES = {"ccg_backlog": "consolidate_run",
407
- "importance_drift": "importance"}
408
-
409
-
410
- # 生效条件:nodes 与 top 必填,遍历索引条目统计缺 verification_basis 或 has_neg_conditions 的候补数 n、no_basis、no_neg 并取最多 top 个 sample,返回含 proxy=True 与 EVOLVE_FIXES['ccg_backlog'] 的字典。
411
- def _ccg_backlog(nodes: dict, top: int) -> dict:
412
- """固化候补量(**索引代理指标**:零读节点文件、确定性、O(N))。
413
-
414
- 真正的固化闸门在 `consolidate`(四要素 + 验证基底 + 白箱 replay,需读正文与 LLM)。
415
- 巡检只要**驱动信号**:索引里的 `verification_basis` / `has_neg_conditions` 已能
416
- 区分「有/无验证基底」「有/无负条件」,足够回答「有没有货等着固化」。
417
-
418
- 代理指标 ≠ 判定结论:报出的是**候补量**,不是「这些节点确实该固化」。
419
- """
420
- n = no_basis = no_neg = 0
421
- sample = []
422
- for nid, e in nodes.items():
423
- mb = not e.get("verification_basis")
424
- mn = not e.get("has_neg_conditions")
425
- no_basis += 1 if mb else 0
426
- no_neg += 1 if mn else 0
427
- if mb or mn:
428
- n += 1
429
- if len(sample) < top:
430
- sample.append(nid)
431
- return {"n": n, "no_basis": no_basis, "no_neg": no_neg, "sample": sample,
432
- "proxy": True, "fix": EVOLVE_FIXES["ccg_backlog"]}
433
-
434
-
435
- # 生效条件:cg 必填,取 cg.index['nodes'](layer 非空时按 layer 过滤),经 weights.recalc(apply=False) 与 _ccg_backlog 汇总,返回 ok=True、readonly=True、dry_run=True 的候选盘点字典。
436
- def evolution_candidates(cg, *, layer: str = None, top: int = 8,
437
- min_delta: float = None) -> dict:
438
- """演化候选盘点(G7):**只读、零读节点文件、确定性、不写盘**。
439
-
440
- 回答「现在有多少该固化 / 该重算重要性的候选」,供常驻巡检与人工决策。
441
- 与 `diagnose()`(故障体检)刻意分开:这里盘的是**演化工作量**,不是故障——
442
- 候补多不代表库有毛病,故 `ok` 恒 True、severity 恒 info。
443
-
444
- `importance_drift` 复用 `weights.recalc(apply=False)`(纯数学,不读文件);
445
- `ccg_backlog` 用索引代理指标。二者都不碰节点内容。
446
- """
447
- nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
448
- if layer:
449
- nodes = {k: v for k, v in nodes.items() if v.get("layer") == layer}
450
- from . import weights
451
- md = weights.APPLY_DELTA if min_delta is None else float(min_delta)
452
- imp = weights.recalc(cg, layer=layer, apply=False, min_delta=md,
453
- dry_run_samples=top)
454
- cb = _ccg_backlog(nodes, top)
455
- idr = {"n": imp["changed"], "scanned": imp["nodes_scanned"],
456
- "min_delta": imp["min_delta"],
457
- "sample": [s["node_id"] for s in imp["samples"]],
458
- "fix": EVOLVE_FIXES["importance_drift"]}
459
- return {"ok": True, "action": "evolve_check", "op": "sustain",
460
- "root": cg.root, "t": time.time(), "readonly": True, "dry_run": True,
461
- "nodes": len(nodes), "top": top, "layer": layer,
462
- "ccg_backlog": cb, "importance_drift": idr,
463
- "candidates": cb["n"] + idr["n"],
464
- "by_fix": {EVOLVE_FIXES["ccg_backlog"]: cb["n"],
465
- EVOLVE_FIXES["importance_drift"]: idr["n"]},
466
- "note": ("只读盘点:未写盘、未改任何节点;固化需 LLM(交人工/另批),"
467
- "重要性重算为确定性动作(有 rollback)")}
468
-
469
-
470
- # 生效条件:给定 cg 后只读汇总(nodes 取自 cg.index、disk 计数、refindex.check_refs),stale_temp_age 传入 _list_stale_temps;check_heartbeat / check_evolution(evolve_top) / check_provenance(provenance_top) 为真时分别追加对应 issue,返回 ok = 无 severity=="warning" 的 issue 连同 stats。
471
- def diagnose(cg, *, name: str = "md_cg", stale_temp_age: float = STALE_TEMP_AGE,
472
- check_heartbeat: bool = True, check_evolution: bool = True,
473
- evolve_top: int = 5, check_provenance: bool = True,
474
- provenance_top: int = 5) -> dict:
475
- """只读体检:返回 issues(带 fix 名)与 stats,不改动任何文件。"""
476
- root = cg.root
477
- issues = []
478
- nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
479
- disk = _count_node_files(root)
480
-
481
- if len(nodes) != disk:
482
- issues.append({"code": "index_drift", "severity": "warning",
483
- "detail": f"索引 {len(nodes)} ≠ 磁盘 {disk}",
484
- "fix": "rebuild_index"})
485
- orphans = [nid for nid, e in nodes.items()
486
- if e.get("path")
487
- and not os.path.exists(os.path.join(root, e["path"]))]
488
- if orphans:
489
- issues.append({"code": "index_orphan", "severity": "warning",
490
- "detail": f"{len(orphans)} 条索引指向不存在的文件",
491
- "sample": orphans[:5], "fix": "rebuild_index"})
492
-
493
- shards = _index_log_shards(root)
494
- if shards:
495
- # severity 必须是 warning:_tick_heal 只在 rep["ok"] 为假(即存在 warning)时
496
- # 才进 heal(),标 info 会让本问题永远进不了修复路径。
497
- issues.append({"code": "index_log_backlog", "severity": "warning",
498
- "detail": f"{len(shards)} 个索引增量分片未合并",
499
- "fix": "compact_index"})
500
-
501
- _acc_log = os.path.join(root, "_access.log")
502
- _acc_lines = 0
503
- try:
504
- if os.path.exists(_acc_log):
505
- with open(_acc_log, encoding="utf-8", errors="replace") as _af:
506
- _acc_lines = sum(1 for _ in _af)
507
- except OSError:
508
- _acc_lines = 0
509
- if _acc_lines > ACCESS_LOG_COMPACT_LINES:
510
- issues.append({"code": "access_log_backlog", "severity": "warning",
511
- "detail": f"访问日志 {_acc_lines} 行未折叠进节点"
512
- f"(> {ACCESS_LOG_COMPACT_LINES})",
513
- "fix": "compact_access"})
514
-
515
- temps = _list_stale_temps(root, stale_temp_age)
516
- if temps:
517
- issues.append({"code": "stale_temps", "severity": "info",
518
- "detail": f"{len(temps)} 个陈旧临时文件",
519
- "sample": [os.path.basename(p) for p in temps[:5]],
520
- "fix": "sweep_temps"})
521
-
522
- half = _half_line_logs(root)
523
- if half:
524
- issues.append({"code": "half_line_logs", "severity": "warning",
525
- "detail": f"{len(half)} 个日志末尾半截行",
526
- "sample": [os.path.basename(p) for p in half[:5]],
527
- "fix": "seal_half_lines"})
528
-
529
- locked = _locked_nodes(cg)
530
- if locked:
531
- issues.append({"code": "locked_nodes", "severity": "info",
532
- "detail": f"{locked} 个私有节点密文不可解(缺密钥)",
533
- "fix": None}) # 权限事实,不自愈
534
-
535
- # ref 漂移 / 悬空(R3):索引是派生物,源变了就报 stale,源没了就报 dangling。
536
- # 只读、不抛;修复动作是重跑 index_code / index_doc(rebuild_refs)。
537
- from . import refindex
538
- refs = refindex.check_refs(cg, ledger=refindex.Ledger(root))
539
- if refs["stale"]:
540
- issues.append({"code": "ref_stale", "severity": "warning",
541
- "detail": f"{len(refs['stale'])} 个 ref 漂移(源文件已改动)",
542
- "sample": [r.get("path") for r in refs["stale"][:5]],
543
- "fix": "rebuild_refs"})
544
- if refs["dangling"]:
545
- issues.append({"code": "ref_dangling", "severity": "warning",
546
- "detail": f"{len(refs['dangling'])} 个 ref 悬空(源文件已删除)",
547
- "sample": [r.get("path") for r in refs["dangling"][:5]],
548
- "fix": "rebuild_refs"})
549
- if refs.get("truncated"):
550
- issues.append({"code": "ref_check_truncated", "severity": "info",
551
- "detail": f"ref 巡检只覆盖前 {refs['max_nodes']} 个节点,结果不完整",
552
- "fix": None}) # 覆盖缺口,显式说出来而非静默
553
-
554
- if check_heartbeat:
555
- st = read_stamp(name)
556
- state = (judge(st["age"], task_running=bool(st.get("task_running")))
557
- if st else "absent")
558
- if state != "ok":
559
- issues.append({"code": "heartbeat_" + state, "severity": "info",
560
- "detail": f"本机心跳状态:{state}", "fix": "beat"})
561
-
562
- # ---- 演化巡检(G7):盘的是「该做多少事」,不是「库有毛病」,
563
- # 故 severity 恒 info(不影响 ok),且全程只读、零读节点文件。
564
- evolve = None
565
- if check_evolution:
566
- evolve = evolution_candidates(cg, top=evolve_top)
567
- cb, idr = evolve["ccg_backlog"], evolve["importance_drift"]
568
- if cb["n"]:
569
- issues.append({"code": "ccg_backlog", "severity": "info",
570
- "detail": (f"{cb['n']} 个固化候补"
571
- f"(索引代理:无验证基底 {cb['no_basis']}"
572
- f" / 无负条件 {cb['no_neg']})"),
573
- "sample": cb["sample"], "fix": cb["fix"],
574
- "proxy": True})
575
- if idr["n"]:
576
- issues.append({"code": "importance_drift", "severity": "info",
577
- "detail": (f"{idr['n']} 个节点结构重要性偏离 "
578
- f"≥{idr['min_delta']}(可重算)"),
579
- "sample": idr["sample"], "fix": idr["fix"]})
580
-
581
- # ---- 派生溯源巡检(G8):只读检出悬空派生边(端点已不在索引内)。
582
- # **无自动修复**:删边等于篡改演进血缘,只报告、由人处置;
583
- # 故 severity 恒 info(不影响 ok、不触发自愈),零读节点文件。
584
- prov = None
585
- if check_provenance:
586
- from . import provenance as _pv
587
- prov = _pv.check(cg, limit=provenance_top)
588
- if prov["dangling_count"]:
589
- issues.append({"code": "provenance_dangling", "severity": "info",
590
- "detail": (f"{prov['dangling_count']} 条派生边悬空"
591
- f"({prov['edges']} 条边中,端点不在索引内)"),
592
- "sample": [f"{r['child']}->{r['parent']}"
593
- for r in prov["dangling"]],
594
- "fix": None}) # 关系事实:只检出,不自动删边
595
-
596
- return {"ok": not any(i["severity"] == "warning" for i in issues),
597
- "root": root, "issues": issues, "t": time.time(),
598
- "evolve": evolve, "provenance": prov,
599
- "stats": {"nodes_indexed": len(nodes), "nodes_on_disk": disk,
600
- "index_log_shards": len(shards), "stale_temps": len(temps),
601
- "half_line_logs": len(half), "locked_nodes": locked,
602
- "ref_checked": refs["checked"],
603
- "ref_stale": len(refs["stale"]),
604
- "ref_dangling": len(refs["dangling"]),
605
- "provenance_edges": (prov["edges"] if prov else 0),
606
- "provenance_dangling":
607
- (prov["dangling_count"] if prov else 0),
608
- "evolve_candidates":
609
- (evolve["candidates"] if evolve else 0)}}
610
-
611
-
612
- # --------------------------------------------------------------------------
613
- # 自愈
614
- # --------------------------------------------------------------------------
615
-
616
- # 生效条件:path 必填,以 'ab' 模式向该路径追加写入单个换行 b'\n',无返回值。
617
- def _seal_half_line(path: str):
618
- with open(path, "ab") as f:
619
- f.write(b"\n")
620
-
621
-
622
- # 生效条件:root/op/action 必填,向 root/SUSTAIN_LOG(模块常量)追加一行含 t/op/action/detail(截断 200)/pid 的 JSONL,无返回值。
623
- def _audit(root: str, op: str, action: str, detail: str = ""):
624
- append_jsonl(os.path.join(root, SUSTAIN_LOG),
625
- {"t": time.time(), "op": op, "action": action,
626
- "detail": str(detail)[:200], "pid": os.getpid()})
627
-
628
-
629
- # 生效条件:按 diagnose(cg, name=name, stale_temp_age=stale_temp_age) 的 issues code 集合分派——命中 index_drift/index_orphan 重建索引、ref_stale/ref_dangling 按 ref 重建源索引、index_log_backlog 合并索引分片、stale_temps 清理陈旧临时文件、half_line_logs 修补半截日志;ccg_backlog 仅 allow_evolve=True 且 reflect_fn 非 None 时才 consolidate(否则记 needs_llm),importance_drift 仅 allow_evolve=True 时才重算重要性(否则记 evolve_disabled);dry_run=True 时各动作只记入 actions 不落盘,返回含 after["ok"]、dry_run、actions、before/after 的 stats 与 t 的 dict;
630
- def heal(cg, *, name: str = "md_cg", dry_run: bool = False,
631
- stale_temp_age: float = STALE_TEMP_AGE,
632
- allow_evolve: bool = False, reflect_fn=None, verify_fn=None) -> dict:
633
- """按诊断结果修复派生物。dry_run=True 时只列动作、不落盘。
634
-
635
- 演化类动作(G7)默认**不动**,须显式 `allow_evolve=True` 才放行,且只放行
636
- **确定性**动作(重要性重算,有 rollback);依赖 LLM 的固化永不自动跑。
637
- """
638
- before = diagnose(cg, name=name, stale_temp_age=stale_temp_age)
639
- codes = {i["code"] for i in before["issues"]}
640
- root = cg.root
641
- actions = []
642
-
643
- # 生效条件:闭包 dry_run 为真时向 actions 追加 {"code": code, "detail": detail, "applied": False} 并返回;否则调用 fn(),成功追加 applied=True/ok=True,抛异常时追加 applied=True/ok=False 与 error,最后执行 _audit(root, "heal", code, detail);
644
- def act(code: str, detail: str, fn):
645
- if dry_run:
646
- actions.append({"code": code, "detail": detail, "applied": False})
647
- return
648
- try:
649
- fn()
650
- actions.append({"code": code, "detail": detail,
651
- "applied": True, "ok": True})
652
- except Exception as e: # 自愈失败不能拖垮进程
653
- actions.append({"code": code, "detail": detail, "applied": True,
654
- "ok": False, "error": f"{type(e).__name__}: {e}"})
655
- _audit(root, "heal", code, detail)
656
-
657
- if "index_drift" in codes or "index_orphan" in codes:
658
- act("rebuild_index", "重建索引(漂移 / 孤儿)", cg.rebuild_index)
659
- if "ref_stale" in codes or "ref_dangling" in codes:
660
- from . import refindex as _ri
661
- _led = _ri.Ledger(root)
662
- act("rebuild_refs", "按 ref 重建源索引(修复漂移;悬空需人工处置)",
663
- lambda: _ri.rebuild(cg, ledger=_led))
664
- if "index_log_backlog" in codes:
665
- act("flush_index", "合并索引增量分片", cg.flush)
666
-
667
- # 生效条件:闭包内先 cg.flush()(把内存 _dirty 落进分片)再 cg.compact_index()
668
- # (读「_index.json 快照 + 分片」写回快照并清空分片);顺序不可颠倒——compact_index
669
- # 以整体替换 self.index,未 flush 的内存条目会丢。
670
- def _compact_index():
671
- cg.flush()
672
- return cg.compact_index()
673
- if "index_log_backlog" in codes:
674
- act("compact_index", "分片折叠进 _index.json(快照落盘)", _compact_index)
675
- if "access_log_backlog" in codes:
676
- act("compact_access", "折叠访问日志进 access_count / last_access",
677
- cg.compact_access)
678
- if "stale_temps" in codes:
679
- paths = _list_stale_temps(root, stale_temp_age)
680
-
681
- # 生效条件:对闭包变量 paths 中每个路径尝试 os.remove(p),单个路径的 OSError 被吞掉后继续处理后续路径;
682
- def _sweep():
683
- for p in paths:
684
- try:
685
- os.remove(p)
686
- except OSError:
687
- pass
688
- act("sweep_temps", f"清理 {len(paths)} 个陈旧临时文件", _sweep)
689
- if "half_line_logs" in codes:
690
- paths = _half_line_logs(root)
691
- act("seal_half_lines", f"修补 {len(paths)} 个半截日志行",
692
- lambda: [_seal_half_line(p) for p in paths])
693
-
694
- # ---- 演化类修复(G7):默认关闭,且只放行确定性动作 ----
695
- if "ccg_backlog" in codes:
696
- det = next((i for i in before["issues"] if i["code"] == "ccg_backlog"), {})
697
- detail = f"{det.get('detail', '固化候补')};固化需 LLM 反思/验证"
698
- if allow_evolve and reflect_fn is not None:
699
- from . import consolidate as _cd
700
-
701
- # 生效条件:调用时返回 _cd.consolidate(cg.root, apply=True, reflect_fn=reflect_fn, verify_fn=verify_fn);
702
- def _consolidate():
703
- return _cd.consolidate(cg.root, apply=True,
704
- reflect_fn=reflect_fn,
705
- verify_fn=verify_fn)
706
- act("consolidate_run", detail, _consolidate)
707
- else:
708
- actions.append({"code": "consolidate_run", "detail": detail,
709
- "applied": False, "reason": "needs_llm"})
710
- if "importance_drift" in codes:
711
- if allow_evolve:
712
- from . import weights
713
-
714
- # 生效条件:调用时返回 weights.recalc(cg, apply=True, actor="sustain_evolve");
715
- def _importance():
716
- return weights.recalc(cg, apply=True, actor="sustain_evolve")
717
- act("importance", "重算结构重要性(确定性;可 rollback)", _importance)
718
- else:
719
- actions.append({"code": "importance",
720
- "detail": "重算结构重要性(确定性动作)",
721
- "applied": False, "reason": "evolve_disabled"})
722
-
723
- after = (before if dry_run
724
- else diagnose(cg, name=name, stale_temp_age=stale_temp_age))
725
- return {"ok": after["ok"], "dry_run": dry_run, "actions": actions,
726
- "before": before["stats"], "after": after["stats"],
727
- "t": time.time()}
728
-
729
-
730
- # --------------------------------------------------------------------------
731
- # 会话水位(重启续接)
732
- # --------------------------------------------------------------------------
733
-
734
- # 生效条件:以 root 实例化后账本固定指向 os.path.join(root, LEDGER_FILE),其 _load/_save/note/resume_point/sessions/summary 均以该路径为唯一读写对象;
735
- class SessionLedger:
736
- """会话水位账本:<root>/_sessions.json。
737
-
738
- 与 sources.Ingestor 的水位互补:Ingestor 记「某个源读到哪」,
739
- 这里记「某个会话发生过什么」——重启后按会话给出续接点。
740
- """
741
-
742
- # 生效条件:传入 root 时置 self.root=root 且 self.path=os.path.join(root, LEDGER_FILE),不做其他校验;
743
- def __init__(self, root: str):
744
- self.root = root
745
- self.path = os.path.join(root, LEDGER_FILE)
746
-
747
- # 生效条件:self.path(root/LEDGER_FILE)可读且 json 结果为 dict、且 d.get("sessions") 也是 dict 时返回该 d;self.path 不可读、json.load 抛 ValueError/OSError、或 d 非 dict / sessions 非 dict 时返回 {"schema": 1, "sessions": {}};
748
- def _load(self):
749
- if os.path.exists(self.path):
750
- try:
751
- with open(self.path, encoding="utf-8") as f:
752
- d = json.load(f)
753
- if isinstance(d, dict) and isinstance(d.get("sessions"), dict):
754
- return d
755
- except (ValueError, OSError):
756
- pass
757
- return {"schema": 1, "sessions": {}}
758
-
759
- # 生效条件:传入 d 时执行 atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1)),自身无返回值;
760
- def _save(self, d):
761
- atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1))
762
-
763
- # 生效条件:以 session 为键 setdefault 会话记录后写 events=int(s.get("events") or 0)+int(n)(n 默认 1,n=0 时计数不变),t 非 None 时写 last_t 且 first_t 为空时一并写入,seq 非 None 时写 last_seq,actor 为真值时写 actor,最后写 updated_at 并 _save,返回 dict(s, session=session);
764
- def note(self, session: str, *, t=None, seq=None, n: int = 1,
765
- actor: str = None) -> dict:
766
- """记一笔会话活动(事件计数 + 最后 (t, seq) 水位)。"""
767
- d = self._load()
768
- s = d["sessions"].setdefault(
769
- session, {"events": 0, "first_t": None, "last_t": None})
770
- s["events"] = int(s.get("events") or 0) + int(n)
771
- if t is not None:
772
- s["last_t"] = float(t)
773
- if s.get("first_t") is None:
774
- s["first_t"] = float(t)
775
- if seq is not None:
776
- s["last_seq"] = seq
777
- if actor:
778
- s["actor"] = actor
779
- s["updated_at"] = time.time()
780
- self._save(d)
781
- return dict(s, session=session)
782
-
783
- # 生效条件:_load()["sessions"].get(session) 为假值(键缺失或值为空 dict)时返回 {"session": session, "resume": None, "events": 0};否则返回 {"session": session, "events": s.get("events", 0), "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")}, "last_t": s.get("last_t"), "actor": s.get("actor")};
784
- def resume_point(self, session: str) -> dict:
785
- """重启续接点:(t, seq) 之后的事件才是新的。"""
786
- s = self._load()["sessions"].get(session)
787
- if not s:
788
- return {"session": session, "resume": None, "events": 0}
789
- return {"session": session, "events": s.get("events", 0),
790
- "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")},
791
- "last_t": s.get("last_t"), "actor": s.get("actor")}
792
-
793
- # 生效条件:无参调用时返回 dict(self._load()["sessions"]) 的浅拷贝,账本缺失/损坏时 _load 回落默认值故此处为 {};
794
- def sessions(self) -> dict:
795
- return dict(self._load()["sessions"])
796
-
797
- # 生效条件:取 d=_load()["sessions"] 后返回 {"sessions": len(d), "events": sum(int(v.get("events") or 0)), "latest": max(v.get("last_t") or 0) if d else None},d 为空时 latest 为 None;
798
- def summary(self) -> dict:
799
- d = self._load()["sessions"]
800
- return {"sessions": len(d),
801
- "events": sum(int(v.get("events") or 0) for v in d.values()),
802
- "latest": max((v.get("last_t") or 0) for v in d.values())
803
- if d else None}
804
-
805
-
806
- # 生效条件:os.path.exists(os.path.join(cg.root, "_sources.json")) 为真且 json.load 成功时返回 dict((d or {}).get("sources") or {})(d 或 sources 为假值即回落 {});该路径不可读或 json.load 抛 ValueError/OSError 时返回 {};
807
- def watermarks(cg) -> dict:
808
- """源视角水位快照(读 sources.Ingestor 的 _sources.json)。"""
809
- p = os.path.join(cg.root, "_sources.json")
810
- if not os.path.exists(p):
811
- return {}
812
- try:
813
- with open(p, encoding="utf-8") as f:
814
- d = json.load(f)
815
- except (ValueError, OSError):
816
- return {}
817
- return dict((d or {}).get("sources") or {})
818
-
819
-
820
- # --------------------------------------------------------------------------
821
- # 常驻循环
822
- # --------------------------------------------------------------------------
823
-
824
- # 生效条件:传入 cg 即构造实例并把 self.cg 指向它,name/beat_interval/heal_interval/auto_heal/scrub_interval/auto_scrub/evolve_interval/auto_evolve/tidy_interval/auto_tidy 用各默认值(DEFAULT_* 与 False/True)经 float()/bool() 落为 self 属性,ledger 为假值(默认 None)时回落 SessionLedger(cg.root),d 经 net_dir(d) 赋值,其余运行态字段初始化为 False/None/空列表/空 Event/Lock
825
- class SustainLoop:
826
- """常驻自维持循环:后台线程周期心跳 + 周期巡检 + 必要时自愈。
827
-
828
- daemon 线程,进程退出即消失;`stop()` 会清掉自己的心跳戳,
829
- 让对端立刻看到「正常下线」而不是「失联」。
830
- """
831
-
832
- # 生效条件:传入 cg 时按 name 与各 DEFAULT_* 默认值初始化——self.d=net_dir(d)(d 假值时回落 MDCG_SUSTAIN_DIR/~/ .mdcg/sustain)、self.ledger=ledger or SessionLedger(cg.root)(ledger 假值时新建),beat/heal/scrub/evolve/tidy 间隔 float() 化、auto_heal/auto_scrub/auto_evolve/auto_tidy bool() 化后存为实例属性;
833
- def __init__(self, cg, name: str = "md_cg", *,
834
- beat_interval: float = DEFAULT_BEAT_INTERVAL,
835
- heal_interval: float = DEFAULT_HEAL_INTERVAL,
836
- auto_heal: bool = True, d: str = None,
837
- ledger: SessionLedger = None,
838
- scrub_interval: float = DEFAULT_SCRUB_INTERVAL,
839
- auto_scrub: bool = False,
840
- evolve_interval: float = DEFAULT_EVOLVE_INTERVAL,
841
- auto_evolve: bool = False,
842
- tidy_interval: float = DEFAULT_TIDY_INTERVAL,
843
- auto_tidy: bool = False):
844
- self.cg = cg
845
- self.name = name
846
- self.beat_interval = float(beat_interval)
847
- self.heal_interval = float(heal_interval)
848
- self.auto_heal = bool(auto_heal)
849
- self.d = net_dir(d)
850
- self.ledger = ledger or SessionLedger(cg.root)
851
- self.scrub_interval = float(scrub_interval)
852
- self.auto_scrub = bool(auto_scrub)
853
- self.evolve_interval = float(evolve_interval)
854
- self.auto_evolve = bool(auto_evolve)
855
- self.tidy_interval = float(tidy_interval)
856
- self.auto_tidy = bool(auto_tidy)
857
- self.task_running = False
858
- self.beats = 0
859
- self.last_beat = None
860
- self.last_diagnose = None
861
- self.heals = []
862
- self.last_scrub = None
863
- self.scrubs = []
864
- self.last_evolve = None
865
- self.evolves = []
866
- self.last_tidy = None
867
- self.tidys = []
868
- # 数据健康不变量断言集结论(Pi⑤):与 tidy 同节奏,只取结论不落盘
869
- self.last_conformance = None
870
- self._started_at = None
871
- self._th = None
872
- self._stop = threading.Event()
873
- self._lock = threading.Lock()
874
-
875
- # ---- 心跳 ----
876
-
877
- # 生效条件:task_running 非 None 时先置 self.task_running=bool(task_running),再 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0),beats 自增并记 last_beat=rec["ts"],返回该 rec;
878
- def beat(self, task_running: bool = None) -> dict:
879
- if task_running is not None:
880
- self.task_running = bool(task_running)
881
- rec = write_stamp(
882
- self.name, self.d, task_running=self.task_running, root=self.cg.root,
883
- uptime=round(time.time() - self._started_at, 3)
884
- if self._started_at else 0.0)
885
- self.beats += 1
886
- self.last_beat = rec["ts"]
887
- return rec
888
-
889
- # ---- 生命周期 ----
890
-
891
- # 生效条件:不适用(无必需形参与模块级常量)
892
- def start(self):
893
- if self._th is not None and self._th.is_alive():
894
- return self
895
- self._started_at = time.time()
896
- self._stop.clear()
897
- self.beat()
898
- self._th = threading.Thread(target=self._run, name="mdcg-sustain",
899
- daemon=True)
900
- self._th.start()
901
- return self
902
-
903
- # 生效条件:调用时置 _stop 事件,self._th 非 None 时 join(timeout)(默认 3.0)后置为 None,随后 clear_stamp(self.name, self.d),返回 self;
904
- def stop(self, timeout: float = 3.0):
905
- self._stop.set()
906
- if self._th is not None:
907
- self._th.join(timeout)
908
- self._th = None
909
- clear_stamp(self.name, self.d)
910
- return self
911
-
912
- # 生效条件:self._stop 未置位期间轮询,按 beat_interval/heal_interval/scrub_interval/evolve_interval/tidy_interval 到期分别执行 beat 与 _tick_heal/_tick_scrub/_tick_evolve/_tick_tidy,各 tick 抛出的异常被吞掉不中断循环,末尾以 _stop.wait(_POLL) 休眠;
913
- def _run(self):
914
- next_beat = time.time() + self.beat_interval
915
- next_heal = time.time() + self.heal_interval
916
- next_scrub = time.time() + self.scrub_interval
917
- next_evolve = time.time() + self.evolve_interval
918
- next_tidy = time.time() + self.tidy_interval
919
- while not self._stop.is_set():
920
- now = time.time()
921
- if now >= next_beat:
922
- try:
923
- self.beat()
924
- except Exception:
925
- pass # 心跳失败不中断常驻
926
- next_beat = now + self.beat_interval
927
- if now >= next_heal:
928
- try:
929
- self._tick_heal()
930
- except Exception:
931
- pass # 巡检失败不中断常驻
932
- next_heal = now + self.heal_interval
933
- if now >= next_scrub:
934
- try:
935
- self._tick_scrub()
936
- except Exception:
937
- pass # 自净失败不中断常驻
938
- next_scrub = now + self.scrub_interval
939
- if now >= next_evolve:
940
- try:
941
- self._tick_evolve()
942
- except Exception:
943
- pass # 演化巡检失败不中断常驻
944
- next_evolve = now + self.evolve_interval
945
- if now >= next_tidy:
946
- try:
947
- self._tick_tidy()
948
- except Exception:
949
- pass # 整理巡检失败不中断常驻
950
- next_tidy = now + self.tidy_interval
951
- self._stop.wait(_POLL)
952
-
953
- # 生效条件:以 apply=self.auto_tidy 调 writelimit.tidy_contextual(self.cg, actor="sustain_tidy"),把 t/scanned/groups/members/applied_count/auto_tidy 记入 self.last_tidy 与 tidys(仅保留最近 20 条),随后调 _tick_conformance();auto_tidy=False(默认)时只盘点不落盘;
954
- def _tick_tidy(self):
955
- """整理巡检(contextual 流水治理·读侧):同构组聚合 + 成员降权。
956
-
957
- 确定性动作、永不删节点;`auto_tidy=False`(默认)只盘点不落盘。
958
- 治理对象:单日批次流水(「批次247收官记忆」×163 那类同模板写入)
959
- —— 写入侧限流(writelimit.check)拦增量,本巡检收敛存量。
960
- """
961
- from . import writelimit
962
- r = writelimit.tidy_contextual(self.cg, apply=self.auto_tidy,
963
- actor="sustain_tidy")
964
- rec = {"t": r["t"], "scanned": r["scanned"], "groups": r["groups"],
965
- "members": r["members"],
966
- "applied_count": r.get("applied_count", 0),
967
- "auto_tidy": self.auto_tidy}
968
- self.last_tidy = rec
969
- with self._lock:
970
- self.tidys.append(rec)
971
- self.tidys = self.tidys[-20:]
972
- self._tick_conformance()
973
-
974
- # 生效条件:把 conformance.report_summary(self.cg) 的结论写入 self.last_conformance;该调用抛异常时写入 {"ok": False, "verdict": "BLINDSPOT", "error": "<类型名>: <消息>"};
975
- def _tick_conformance(self):
976
- """数据健康不变量断言集(Pi⑤)——与 tidy 同节奏,**只取结论**。
977
-
978
- 复用 tidy 的 6h 节奏而不新开周期:两者都是「存量数据体检」,且都是
979
- 只读巡检。纪律:本断言集**只告警不改数据**,故 `auto_*` 在此无意义;
980
- `check_paths=False` 跳过 1.1 万次 stat,`_audit.jsonl` 只读尾窗。
981
- """
982
- from . import conformance
983
- try:
984
- self.last_conformance = conformance.report_summary(self.cg)
985
- except Exception as e: # noqa: BLE001
986
- self.last_conformance = {"ok": False, "verdict": "BLINDSPOT",
987
- "error": f"{type(e).__name__}: {e}"}
988
-
989
- # 生效条件:恒以 evolution_candidates(self.cg) 只读盘点并记入 last_evolve 与 evolves(保留最近 20 条);仅当 self.auto_evolve 为真且 ev["importance_drift"]["n"] 为真时才额外执行 weights.recalc(self.cg, apply=True, actor="sustain_evolve"),其异常写入 rec["applied"];
990
- def _tick_evolve(self):
991
- """演化巡检(G7):盘点固化/重要性候选 —— 让「有能力」变成「有驱动」。
992
-
993
- 恒只读盘点并记账;`auto_evolve=True` 时才额外落盘**确定性**动作
994
- (仅重要性重算,可 rollback)。固化依赖 LLM,巡检只报候补量、交人工。
995
- """
996
- ev = evolution_candidates(self.cg)
997
- rec = {"t": ev["t"], "candidates": ev["candidates"],
998
- "ccg_backlog": ev["ccg_backlog"]["n"],
999
- "importance_drift": ev["importance_drift"]["n"],
1000
- "auto_evolve": self.auto_evolve, "applied": []}
1001
- if self.auto_evolve and ev["importance_drift"]["n"]:
1002
- from . import weights
1003
- try:
1004
- r = weights.recalc(self.cg, apply=True, actor="sustain_evolve")
1005
- rec["applied"].append({"fix": "importance",
1006
- "written": r["written"],
1007
- "batch": r["batch"]})
1008
- except Exception as e: # 演化失败不能拖垮常驻
1009
- rec["applied"].append({"fix": "importance",
1010
- "error": f"{type(e).__name__}: {e}"})
1011
- self.last_evolve = rec
1012
- with self._lock:
1013
- self.evolves.append(rec)
1014
- self.evolves = self.evolves[-20:]
1015
-
1016
- # 生效条件:以 dry_run=not self.auto_scrub 调 scrub.sweep(self.cg),把 t/ok/n_issues/n_high_medium/applied/dry_run 记入 last_scrub 与 scrubs(保留最近 20 条);auto_scrub=False(默认)时 dry_run=True 只读巡检;
1017
- def _tick_scrub(self):
1018
- """记忆自净:抽查 → 联想 → 去污染 → 校准偏差。
1019
-
1020
- `auto_scrub=False`(默认)时只做只读巡检并记账,不动任何节点;
1021
- 开启后才执行去污染(仍只做可逆动作、永不删节点)。
1022
- """
1023
- from . import scrub
1024
- rep = scrub.sweep(self.cg, dry_run=not self.auto_scrub)
1025
- rec = {"t": rep["t"], "ok": rep["ok"],
1026
- "n_issues": rep["audit"]["n_issues"],
1027
- "n_high_medium": rep["n_high_medium"],
1028
- "applied": rep["decontaminate"]["applied"],
1029
- "dry_run": rep["dry_run"]}
1030
- self.last_scrub = rec
1031
- with self._lock:
1032
- self.scrubs.append(rec)
1033
- self.scrubs = self.scrubs[-20:]
1034
-
1035
- # 生效条件:先 diagnose(self.cg, name=self.name) 并将 ok 与 issues code 记入 last_diagnose;仅当 self.auto_heal 为真且 rep["ok"] 为假时才调 heal(self.cg, name=self.name),其 actions 非空时把各 action 的 code 记入 heals(保留最近 20 条);
1036
- def _tick_heal(self):
1037
- rep = diagnose(self.cg, name=self.name)
1038
- self.last_diagnose = {"t": time.time(), "ok": rep["ok"],
1039
- "issues": [i["code"] for i in rep["issues"]]}
1040
- if not (self.auto_heal and not rep["ok"]):
1041
- return
1042
- res = heal(self.cg, name=self.name)
1043
- if res["actions"]:
1044
- with self._lock:
1045
- self.heals.append({"t": res["t"],
1046
- "actions": [a["code"] for a in res["actions"]]})
1047
- self.heals = self.heals[-20:]
1048
-
1049
- # ---- 状态 ----
1050
-
1051
- # 生效条件:以 read_stamp(self.name, self.d) 判定 state(有戳走 judge(age, interval=self.beat_interval, task_running=...),无戳为 "stopped"),返回含 name/running/pid/uptime(无 _started_at 时为 0.0)/beats/last_beat/各 interval 与 auto_* 开关/heals[-5:]/evolves[-5:]/tidys[-5:]/peers(self.d)/ledger.summary() 的 dict;
1052
- def status(self) -> dict:
1053
- st = read_stamp(self.name, self.d)
1054
- state = (judge(st["age"], interval=self.beat_interval,
1055
- task_running=bool(st.get("task_running")))
1056
- if st else "stopped")
1057
- return {"name": self.name, "running": bool(self._th
1058
- and self._th.is_alive()),
1059
- "pid": os.getpid(),
1060
- "uptime": round(time.time() - self._started_at, 3)
1061
- if self._started_at else 0.0,
1062
- "beats": self.beats, "last_beat": self.last_beat,
1063
- "beat_interval": self.beat_interval,
1064
- "heal_interval": self.heal_interval,
1065
- "auto_heal": self.auto_heal, "state": state,
1066
- "task_running": self.task_running,
1067
- "last_diagnose": self.last_diagnose,
1068
- "heals": self.heals[-5:],
1069
- "scrub_interval": self.scrub_interval,
1070
- "auto_scrub": self.auto_scrub,
1071
- "last_scrub": self.last_scrub,
1072
- "evolve_interval": self.evolve_interval,
1073
- "auto_evolve": self.auto_evolve,
1074
- "last_evolve": self.last_evolve,
1075
- "evolves": self.evolves[-5:],
1076
- "last_tidy": self.last_tidy,
1077
- "tidys": self.tidys[-5:],
1078
- "last_conformance": self.last_conformance,
1079
- "peers": peers(self.d),
1080
- "sessions": self.ledger.summary()}
1081
-
1082
-
1083
- _LOOPS = {}
1084
- _LOOPS_LOCK = threading.Lock()
1085
-
1086
-
1087
- # 生效条件:cg 必填,以 (os.path.abspath(cg.root), name) 为键在模块级 _LOOPS 中复用已有实例或新建 SustainLoop 并返回该实例。
1088
- def ensure_loop(cg, name: str = "md_cg", **kw) -> SustainLoop:
1089
- """按 (root, name) 复用同一个常驻循环(避免重复线程 / 重复戳)。"""
1090
- key = (os.path.abspath(cg.root), name)
1091
- with _LOOPS_LOCK:
1092
- lp = _LOOPS.get(key)
1093
- if lp is None:
1094
- lp = SustainLoop(cg, name=name, **kw)
1095
- _LOOPS[key] = lp
1096
- return lp
1097
-
1098
-
1099
- # 生效条件:cg 必填,返回 _LOOPS[(os.path.abspath(cg.root), name)],该键未登记时返回 None。
1100
- def get_loop(cg, name: str = "md_cg"):
1101
- return _LOOPS.get((os.path.abspath(cg.root), name))
1102
-
1103
-
1104
- # 生效条件:模块级 _LOOPS 非空时清空该表并对每个循环调用 stop()(异常被吞),无返回值。
1105
- def stop_all():
1106
- """测试 / 进程退出用:停掉本进程创建的所有常驻循环。"""
1107
- with _LOOPS_LOCK:
1108
- loops = list(_LOOPS.values())
1109
- _LOOPS.clear()
1110
- for lp in loops:
1111
- try:
1112
- lp.stop()
1113
- except Exception:
1114
- pass
1115
-
1116
-
1117
- # 生效条件:以 read_stamp(name) 有戳时返回 heartbeat={'age': round(st["age"],1), 'state': judge(...), 'pid', 'task_running'}、无戳时 {'state':'absent'},loop 取 get_loop(cg, name) 有值时给 running/beats/state、无值时 {'running': False},并附带 SessionLedger(cg.root).summary()、scrub_summary(cg)、evolve_summary(cg)、provenance_summary(cg);
1118
- def summary(cg, name: str = "md_cg") -> dict:
1119
- """并入 health_os 的轻量摘要(只读戳与计数,不做巡检)。"""
1120
- st = read_stamp(name)
1121
- lp = get_loop(cg, name)
1122
- return {
1123
- "heartbeat": ({"age": round(st["age"], 1),
1124
- "state": judge(st["age"],
1125
- task_running=bool(st.get("task_running"))),
1126
- "pid": st.get("pid"),
1127
- "task_running": bool(st.get("task_running"))}
1128
- if st else {"state": "absent"}),
1129
- "loop": ({"running": bool(lp._th and lp._th.is_alive()),
1130
- "beats": lp.beats, "state": lp.status()["state"]}
1131
- if lp else {"running": False}),
1132
- "sessions": SessionLedger(cg.root).summary(),
1133
- "scrub": scrub_summary(cg),
1134
- "evolve": evolve_summary(cg),
1135
- "provenance": provenance_summary(cg),
1136
- }
1137
-
1138
-
1139
- # 生效条件:cg 必填,可导入 provenance 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1140
- def provenance_summary(cg) -> dict:
1141
- """派生溯源摘要(G8,只读;失败不抛,避免拖垮 health_os)。"""
1142
- try:
1143
- from . import provenance as _pv
1144
- return _pv.summary(cg)
1145
- except Exception: # noqa: BLE001
1146
- return {}
1147
-
1148
-
1149
- # 生效条件:cg 必填,evolution_candidates(cg, top=3) 成功时返回 candidates/ccg_backlog/importance_drift 计数摘要,异常时返回 {}。
1150
- def evolve_summary(cg) -> dict:
1151
- """演化巡检摘要(只读;失败不抛,避免拖垮 health_os)。"""
1152
- try:
1153
- ev = evolution_candidates(cg, top=3)
1154
- return {"candidates": ev["candidates"],
1155
- "ccg_backlog": ev["ccg_backlog"]["n"],
1156
- "importance_drift": ev["importance_drift"]["n"],
1157
- "proxy": True}
1158
- except Exception: # noqa: BLE001
1159
- return {}
1160
-
1161
-
1162
- # 生效条件:cg 必填,可导入 scrub 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1163
- def scrub_summary(cg) -> dict:
1164
- """记忆自净摘要(惰性导入,避免模块加载顺序耦合)。"""
1165
- try:
1166
- from . import scrub
1167
- return scrub.summary(cg)
1168
- except Exception: # noqa: BLE001
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · 持续性自维持(记忆 OS #4):常驻 / 心跳 / 自愈 / 会话续接
3
+
4
+ 路线图「常驻服务:会话 / 心跳 / 自愈」的落地。回答三个问题:
5
+
6
+ ① 别人怎么知道我还活着?—— **心跳戳**
7
+ `<net_dir>/heartbeat.<name>.stamp`(原子写,含 ts / pid / uptime / task_running)。
8
+ 分级判定对齐 Mutual Sustain Loop v1.1:正常 / 警告 / 失联,阈值按心跳间隔的
9
+ 2.5× / 3.5×;**任务执行中阈值放宽**(working factor),避免长任务被误判死亡。
10
+ 戳写在仓库外(默认 ~/.mdcg/sustain),不污染知识库,也不随仓库泄露。
11
+
12
+ ② 进程被杀会不会留下坏状态?—— **自愈(diagnose → heal)**
13
+ 诊断只读、不改动;修复动作幂等且逐条留审计:
14
+ · 索引漂移 / 孤儿索引 → 重建索引(索引是派生物,可安全重建)
15
+ · 索引增量分片积压 → 合并进快照
16
+ · 陈旧临时文件 → 清理(被杀死的写者留下的唯一命名 tmp)
17
+ · 日志半截行 → 补换行(否则下一条记录会粘在断行上)
18
+ · 私有节点不可解 → **只报告不修**(缺密钥是权限事实,不是故障)
19
+ 原则:修复只碰派生物(索引 / 临时文件 / 日志边界),**永不删节点**。
20
+
21
+ ③ 重启后从哪继续?—— **会话水位(SessionLedger)**
22
+ `<root>/_sessions.json` 记录每个会话的 (last_t, last_seq, events),
23
+ 与 `sources.Ingestor` 的 `_sources.json`(源视角水位)互补;重启后
24
+ `resume_point(session)` 直接给出续接点,不重复摄取、不丢事件。
25
+
26
+ ④ 能力不会被饿死?—— **演化巡检(evolve)**
27
+ 自我演化的三类动作(固化 `consolidate` / 重算重要性 `weights` / 去污染 `scrub`)
28
+ 早已具备,缺的是**驱动源**:没人周期性问「现在有多少该固化 / 该重算的候选」。
29
+ `evolution_candidates()` 以**索引快照**为口径做只读盘点(零读节点文件、零写盘、
30
+ 确定性),并挂到常驻循环的 `_tick_evolve()` 上。纪律与 self-heal 一致且更严:
31
+ · 巡检恒只读,`auto_evolve=False`(默认)时**只记账不动库**;
32
+ · 自愈只放行**确定性且可回滚**的动作(重要性重算,有 rollback);
33
+ · 依赖 LLM 的固化**永不自动跑**——巡检报出候选数,交人工另批。
34
+
35
+ ⑤ 演进血缘有没有断?—— **派生溯源巡检(G8)**
36
+ 新增节点在建链时把 `derived_from` 写进 frontmatter 并追加到 `<root>/_link.jsonl`。
37
+ 可台账会丢、节点会被删,于是血缘会出现**悬空边**(子/父节点已不在库里)。
38
+ `diagnose()` 每次都做只读盘点(零读节点文件:索引已带 `derived_from`),把悬空边
39
+ 报为 `provenance_dangling`(severity=info、**无自动修复**)——关系事实的去留由人
40
+ 处置,不给「自动删边」这种会篡改历史的动作。
41
+
42
+ ⑥ 对端挂了谁来救?—— **互维闭环(P-T-110 最小投影 · #30)**
43
+ 两个灵枢互为维生系统:`mutual_watch` 读对端心跳 → 失联则**幂等拉起**
44
+ (pid 探活防误判 + 冷却防风暴)→ **验戳新鲜闭合**:拉起后必须轮询到
45
+ 对端戳变新才算救活,否则如实报 `mutual_peer_unresponsive`——这正是
46
+ mutual-sustain-loop v1.1 §7 的 W4 部署教训(「拉起后应验证对端戳新鲜度,
47
+ 当时缺该校验」)的机制化:不假装成功。`mutual_status` 做双亡检测:
48
+ 自己也失联时互维本身不可信 → 显式上报外部告警语义,绝不静默。
49
+
50
+ 零第三方依赖。
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ import os
56
+ import threading
57
+ import time
58
+
59
+ from . import crypto
60
+ from .datapath import aux_root
61
+ from .fsutil import append_jsonl, atomic_write, ends_mid_line
62
+ from .mdcg import LAYERS
63
+
64
+ STAMP_VERSION = 1
65
+ SUSTAIN_LOG = "_sustain.jsonl"
66
+ LEDGER_FILE = "_sessions.json"
67
+
68
+ DEFAULT_BEAT_INTERVAL = 600.0 # 心跳间隔 10min(对齐 mutual-sustain-loop)
69
+ DEFAULT_HEAL_INTERVAL = 300.0 # 自愈巡检 5min
70
+ DEFAULT_SCRUB_INTERVAL = 3600.0 # 记忆自净(抽查/去污染/校准)1h
71
+ DEFAULT_EVOLVE_INTERVAL = 7200.0 # 演化巡检(固化/重要性候选盘点)2h;只读
72
+ DEFAULT_TIDY_INTERVAL = 21600.0 # 整理巡检(contextual 同构组聚合)6h
73
+ DEFAULT_WARN_FACTOR = 2.5 # 2.5× 心跳间隔 → 警告
74
+ DEFAULT_DEAD_FACTOR = 3.5 # 3.5× → 失联
75
+ DEFAULT_WORKING_FACTOR = 2.0 # 任务执行中阈值 ×2
76
+ STALE_TEMP_AGE = 3600.0 # 临时文件超过 1h 视为陈旧
77
+ ACCESS_LOG_COMPACT_LINES = 500 # 访问日志超过该行数即折叠(否则无上限增长)
78
+ _POLL = 0.2 # 循环轮询步长(常驻进程 CPU 可忽略)
79
+
80
+
81
+ # --------------------------------------------------------------------------
82
+ # 心跳
83
+ # --------------------------------------------------------------------------
84
+
85
+ # 生效条件:d 为真值时返回 d,d 为 None/空串时回落 MDCG_SUSTAIN_DIR,该环境变量也未设或为空串时返回 os.path.join(aux_root(), "sustain")(默认 ~/.mdcg/sustain,可经 MDCG_AUX_ROOT 改);
86
+ def net_dir(d: str = None) -> str:
87
+ """心跳戳目录:显式 → MDCG_SUSTAIN_DIR → aux_root()/sustain(仓库外)。"""
88
+ return (d or os.environ.get("MDCG_SUSTAIN_DIR")
89
+ or os.path.join(aux_root(), "sustain"))
90
+
91
+
92
+ # 生效条件:给定必填 name,返回 net_dir(d) 下 heartbeat.<name>.stamp 的拼接路径。
93
+ def stamp_path(name: str, d: str = None) -> str:
94
+ return os.path.join(net_dir(d), f"heartbeat.{name}.stamp")
95
+
96
+
97
+ # 生效条件:name 必填,组装含模块常量 STAMP_VERSION 与 task_running 的戳记录、原子写入 stamp_path(name,d) 后返回该 rec。
98
+ def write_stamp(name: str, d: str = None, **extra) -> dict:
99
+ """写一次心跳(原子替换)。extra 会并入戳内容(None 值丢弃)。"""
100
+ rec = {"v": STAMP_VERSION, "name": name, "ts": time.time(),
101
+ "pid": os.getpid(),
102
+ "iso": time.strftime("%Y-%m-%dT%H:%M:%S"),
103
+ "task_running": bool(extra.pop("task_running", False))}
104
+ for k, v in extra.items():
105
+ if v is not None:
106
+ rec[k] = v
107
+ atomic_write(stamp_path(name, d),
108
+ json.dumps(rec, ensure_ascii=False, indent=1))
109
+ return rec
110
+
111
+
112
+ # 生效条件:stamp_path(name, d) 所得路径上 os.path.exists 为真且 json.load 结果为 dict 时,返回该 rec 并附加 age=max(0.0, time.time() - float(rec.get("ts") or 0));该路径不可读、json.load 抛 ValueError/OSError 或 rec 非 dict 时返回 None;
113
+ def read_stamp(name: str, d: str = None):
114
+ """读心跳戳并附 age(秒);不存在 / 损坏 → None。"""
115
+ p = stamp_path(name, d)
116
+ if not os.path.exists(p):
117
+ return None
118
+ try:
119
+ with open(p, encoding="utf-8") as f:
120
+ rec = json.load(f)
121
+ except (ValueError, OSError):
122
+ return None
123
+ if not isinstance(rec, dict):
124
+ return None
125
+ rec["age"] = max(0.0, time.time() - float(rec.get("ts") or 0))
126
+ return rec
127
+
128
+
129
+ # 生效条件:对 stamp_path(name,d) 执行 os.remove,OSError 被静默忽略,无返回值。
130
+ def clear_stamp(name: str, d: str = None):
131
+ try:
132
+ os.remove(stamp_path(name, d))
133
+ except OSError:
134
+ pass
135
+
136
+
137
+ # 生效条件:age 为 None 返回 'absent';否则以 factor=working if task_running else 1.0 比较 age 与 interval*warn*factor、interval*dead*factor,分别返回 'ok'/'warning'/'dead'。
138
+ def judge(age, *, interval: float = DEFAULT_BEAT_INTERVAL,
139
+ task_running: bool = False, warn: float = DEFAULT_WARN_FACTOR,
140
+ dead: float = DEFAULT_DEAD_FACTOR,
141
+ working: float = DEFAULT_WORKING_FACTOR) -> str:
142
+ """按心跳年龄分级:ok / warning / dead / absent。
143
+
144
+ task_running=True 时阈值整体放宽 working 倍——长任务期间不写心跳是正常的,
145
+ 若不放宽会把「正在干活」误判成「已死」。
146
+ """
147
+ if age is None:
148
+ return "absent"
149
+ factor = working if task_running else 1.0
150
+ if age <= interval * warn * factor:
151
+ return "ok"
152
+ if age <= interval * dead * factor:
153
+ return "warning"
154
+ return "dead"
155
+
156
+
157
+ # 生效条件:net_dir(d) 可列为目录时遍历 heartbeat.*.stamp 并返回含 age 与 state 的记录列表,否则返回 []。
158
+ def peers(d: str = None):
159
+ """列出本机所有心跳戳(含状态)。"""
160
+ base = net_dir(d)
161
+ if not os.path.isdir(base):
162
+ return []
163
+ out = []
164
+ for fn in sorted(os.listdir(base)):
165
+ if not (fn.startswith("heartbeat.") and fn.endswith(".stamp")):
166
+ continue
167
+ rec = read_stamp(fn[len("heartbeat."):-len(".stamp")], base)
168
+ if rec:
169
+ rec["state"] = judge(rec["age"],
170
+ task_running=bool(rec.get("task_running")))
171
+ out.append(rec)
172
+ return out
173
+
174
+
175
+ # --------------------------------------------------------------------------
176
+ # 互维闭环(P-T-110 最小投影 · #30)
177
+ # --------------------------------------------------------------------------
178
+
179
+ DEFAULT_RESTART_COOLDOWN = 300.0 # 同一对端两次拉起的最小间隔(防风暴)
180
+ DEFAULT_FRESH_TIMEOUT = 60.0 # 拉起后等待对端戳变新鲜的窗口
181
+
182
+
183
+ # 生效条件:pid 可转 int 且 >0 时按探活(os.name=='nt' 用 OpenProcess+WaitForSingleObject,否则 os.kill(pid,0))返回 True,转 int 失败或 pid<=0 或探活失败返回 False。
184
+ def pid_alive(pid) -> bool:
185
+ """进程探活(零第三方依赖):Windows=OpenProcess+WaitForSingleObject;
186
+ posix=os.kill(pid,0)。pid 无效 / 已退出 / 权限外 → False(诚实)。"""
187
+ try:
188
+ pid = int(pid)
189
+ except (TypeError, ValueError):
190
+ return False
191
+ if pid <= 0:
192
+ return False
193
+ if os.name == "nt":
194
+ import ctypes
195
+ SYNCHRONIZE = 0x00100000
196
+ WAIT_TIMEOUT = 0x00000102
197
+ k32 = ctypes.windll.kernel32
198
+ h = k32.OpenProcess(SYNCHRONIZE, False, pid)
199
+ if not h:
200
+ return False
201
+ try:
202
+ return k32.WaitForSingleObject(h, 0) == WAIT_TIMEOUT
203
+ finally:
204
+ k32.CloseHandle(h)
205
+ try:
206
+ os.kill(pid, 0)
207
+ return True
208
+ except OSError:
209
+ return False
210
+
211
+
212
+ # 生效条件:cmd 为 argv 列表时以 stdout/stderr=DEVNULL、不经 shell 的 subprocess.Popen 拉起(nt 附加 DETACHED|NEW_GROUP creationflags),返回该 Popen 对象。
213
+ def _default_spawner(cmd):
214
+ """分离式拉起:argv 列表、不经 shell、输出弃置(Windows 完全脱离父控制台)。"""
215
+ import subprocess
216
+ kw = {}
217
+ if os.name == "nt":
218
+ kw["creationflags"] = (0x00000008 | 0x00000200) # DETACHED|NEW_GROUP
219
+ return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
220
+ stderr=subprocess.DEVNULL, **kw)
221
+
222
+
223
+ # 生效条件:root 与 peer 必填,扫描 root/SUSTAIN_LOG(模块常量)返回 op='mutual'、action='restart' 且 detail 以 peer 开头的记录中最大 t,无匹配返回 0.0。
224
+ def _last_mutual_restart(root: str, peer: str) -> float:
225
+ """_sustain.jsonl 里该对端最近一次互维拉起时间(无 → 0.0,审计即状态)。"""
226
+ last = 0.0
227
+ try:
228
+ with open(os.path.join(root, SUSTAIN_LOG), encoding="utf-8") as f:
229
+ for line in f:
230
+ try:
231
+ r = json.loads(line)
232
+ except ValueError:
233
+ continue
234
+ if (r.get("op") == "mutual" and r.get("action") == "restart"
235
+ and str(r.get("detail") or "").startswith(str(peer))):
236
+ last = max(last, float(r.get("t") or 0.0))
237
+ except OSError:
238
+ pass
239
+ return last
240
+
241
+
242
+ # 生效条件:cg 与 peer 必填,读对端戳判状态:ok/warning 直接返回 action='none';dead/absent 时依次按 pid_alive、restart_cooldown、restart_cmd 有无走 alive_but_stale/restart_cooldown/dead_unhandled/拉起并验戳,返回 out。
243
+ def mutual_watch(cg, peer: str, *, restart_cmd=None, d: str = None,
244
+ interval: float = DEFAULT_BEAT_INTERVAL,
245
+ restart_cooldown: float = DEFAULT_RESTART_COOLDOWN,
246
+ fresh_timeout: float = DEFAULT_FRESH_TIMEOUT, poll: float = 0.5,
247
+ spawner=None) -> dict:
248
+ """互维守护(单侧一次检查):读对端心跳 → 失联则幂等拉起 → 验戳闭合。
249
+
250
+ 判定序(P-T-110 语义,全链路审计进 _sustain.jsonl):
251
+ ok/warning → 不动作;
252
+ dead/absent:
253
+ ① 误判防护:戳里 pid 仍存活 → `alive_but_stale`(长任务未标
254
+ task_running?)——**不拉起**,不杀活进程;
255
+ ② 冷却:距上次拉起 < restart_cooldown → `restart_cooldown`;
256
+ ③ 无 restart_cmd → `dead_unhandled`(ok=False,诚实上报没手段,
257
+ 不假装能救);
258
+ ④ 拉起(spawner 或分离式 Popen)→ **验戳新鲜闭合**(W4 补丁):
259
+ 轮询对端戳 ts 超过拉起前值才算 `recovered=True`;超时 →
260
+ `recovered=False` + `mutual_peer_unresponsive`——对端拉起后
261
+ 互维未激活(v1.1 §7 的 W4 场景)会被显式暴露,不假装成功。
262
+ """
263
+ rec = read_stamp(peer, d)
264
+ state = (judge(rec["age"], interval=interval,
265
+ task_running=bool(rec.get("task_running")))
266
+ if rec else "absent")
267
+ out = {"ok": True, "peer": peer, "state": state, "t": time.time(),
268
+ "action": "none", "recovered": None}
269
+ if state in ("ok", "warning"):
270
+ return out
271
+ if rec and pid_alive(rec.get("pid")):
272
+ out.update(action="alive_but_stale",
273
+ note="对端进程存活但戳陈旧(长任务未标 task_running?)——不拉起")
274
+ _audit(cg.root, "mutual", "alive_but_stale", peer)
275
+ return out
276
+ last = _last_mutual_restart(cg.root, peer)
277
+ if last and (time.time() - last) < restart_cooldown:
278
+ out.update(action="restart_cooldown",
279
+ note="距上次拉起 %.1fs,冷却中" % (time.time() - last))
280
+ return out
281
+ if not restart_cmd:
282
+ out.update(ok=False, action="dead_unhandled",
283
+ note="对端失联且未提供 restart_cmd——诚实上报,不假装能救")
284
+ _audit(cg.root, "mutual", "dead_unhandled", peer)
285
+ return out
286
+ before_ts = float((rec or {}).get("ts") or 0.0)
287
+ spawn = spawner or _default_spawner
288
+ try:
289
+ spawn(restart_cmd)
290
+ except Exception as e: # noqa: BLE001
291
+ out.update(ok=False, action="restart_error",
292
+ error="%s: %s" % (type(e).__name__, e))
293
+ _audit(cg.root, "mutual", "restart_error", peer)
294
+ return out
295
+ deadline = time.time() + fresh_timeout
296
+ while time.time() < deadline:
297
+ r2 = read_stamp(peer, d)
298
+ if r2 and float(r2.get("ts") or 0.0) > before_ts:
299
+ out.update(action="restarted", recovered=True, fresh_ts=r2.get("ts"))
300
+ _audit(cg.root, "mutual", "restart", peer)
301
+ return out
302
+ time.sleep(poll)
303
+ out.update(ok=False, action="restarted", recovered=False,
304
+ note=("拉起命令已执行但对端戳未更新(互维未激活?)——"
305
+ "mutual_peer_unresponsive,不假装成功"))
306
+ _audit(cg.root, "mutual", "restart_unverified", peer)
307
+ return out
308
+
309
+
310
+ # 生效条件:cg 与 peer 必填,分别按 name 与 peer 读戳判状态,仅当自身与对端状态均属 ('dead','absent') 时返回 ok=False、both_dead=True。
311
+ def mutual_status(cg, peer: str, *, d: str = None, name: str = "md_cg",
312
+ interval: float = DEFAULT_BEAT_INTERVAL) -> dict:
313
+ """互维状态 + 双亡检测:自己也失联时互维不可信 → 显式外部告警语义。"""
314
+ mine, theirs = read_stamp(name, d), read_stamp(peer, d)
315
+ my_state = (judge(mine["age"], interval=interval,
316
+ task_running=bool(mine.get("task_running")))
317
+ if mine else "absent")
318
+ peer_state = (judge(theirs["age"], interval=interval,
319
+ task_running=bool(theirs.get("task_running")))
320
+ if theirs else "absent")
321
+ dead = ("dead", "absent")
322
+ both_dead = my_state in dead and peer_state in dead
323
+ return {"ok": not both_dead, "self": my_state, "peer": peer_state,
324
+ "both_dead": both_dead,
325
+ "note": ("双向同时失联:互维本身已不可信,须外部告警(P-T-110 风险表)"
326
+ if both_dead else "互维链路可用")}
327
+
328
+
329
+ # --------------------------------------------------------------------------
330
+ # 诊断(只读)
331
+ # --------------------------------------------------------------------------
332
+
333
+ # 生效条件:root 必填,对模块常量 LAYERS 各层 os.walk 统计以 .md 结尾的文件名个数并返回 n(不解析正文)。
334
+ def _count_node_files(root: str) -> int:
335
+ """轻量统计磁盘节点数(只数文件名,不解析正文)。"""
336
+ n = 0
337
+ for layer in LAYERS:
338
+ for _dp, _dirs, files in os.walk(os.path.join(root, layer)):
339
+ n += sum(1 for f in files if f.endswith(".md"))
340
+ return n
341
+
342
+
343
+ # 生效条件:root 与 older_than 必填,返回 root 及其 LAYERS 各层下以 '.' 开头、名含 '.tmp-' 且 mtime 早于 now-older_than 的路径列表。
344
+ def _list_stale_temps(root: str, older_than: float):
345
+ now, out = time.time(), []
346
+ bases = [root] + [os.path.join(root, l) for l in LAYERS]
347
+ for base in bases:
348
+ if not os.path.isdir(base):
349
+ continue
350
+ for name in os.listdir(base):
351
+ if ".tmp-" not in name or not name.startswith("."):
352
+ continue
353
+ p = os.path.join(base, name)
354
+ try:
355
+ if now - os.path.getmtime(p) > older_than:
356
+ out.append(p)
357
+ except OSError:
358
+ pass
359
+ return out
360
+
361
+
362
+ # 生效条件:root 必填,返回 root 顶层以 .jsonl/.log 结尾且 ends_mid_line 为真的路径列表;root 非目录返回 []。
363
+ def _half_line_logs(root: str):
364
+ """顶层行式日志里末尾不是换行的(写者被杀留下的半截记录)。"""
365
+ out = []
366
+ if not os.path.isdir(root):
367
+ return out
368
+ for name in sorted(os.listdir(root)):
369
+ if not (name.endswith(".jsonl") or name.endswith(".log")):
370
+ continue
371
+ p = os.path.join(root, name)
372
+ if os.path.isfile(p) and ends_mid_line(p):
373
+ out.append(p)
374
+ return out
375
+
376
+
377
+ # 生效条件:root/_index_log 可列为目录时返回其中 .log 结尾的文件名列表,否则返回 []。
378
+ def _index_log_shards(root: str):
379
+ d = os.path.join(root, "_index_log")
380
+ if not os.path.isdir(d):
381
+ return []
382
+ return [f for f in os.listdir(d) if f.endswith(".log")]
383
+
384
+
385
+ # 生效条件:cg 具备 crypto_status 且其返回 unlocked 为假时,返回 cg.index['nodes'] 中 sensitivity 属 crypto.ENCRYPTED_LEVELS 的条目数;属性缺失、调用异常或已解锁时返回 0。
386
+ def _locked_nodes(cg) -> int:
387
+ """加密引擎在「未解锁」状态下被隔离的私有节点数(缺密钥 = 权限事实)。"""
388
+ if not hasattr(cg, "crypto_status"):
389
+ return 0
390
+ try:
391
+ st = cg.crypto_status() or {}
392
+ except Exception:
393
+ return 0
394
+ if st.get("unlocked"):
395
+ return 0
396
+ nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
397
+ # H-4 止血:取用前先取快照——`nodes` 是**共享可变面**(前台 add/flush 会改
398
+ # 同一 dict),裸迭代撞上并发写即 RuntimeError('dictionary changed size
399
+ # during iteration')(N138,FI-M04)。list() 拷贝在 C 层一次完成(迭代期间
400
+ # 不释放 GIL),故快照自身原子;判据与结果逐位不变(只换取用方式)。
401
+ return sum(1 for e in list(nodes.values())
402
+ if (e.get("sensitivity") or "") in crypto.ENCRYPTED_LEVELS)
403
+
404
+
405
+ # --------------------------------------------------------------------------
406
+ # 演化巡检(G7:给「固化 / 重要性」能力装上驱动源)
407
+ # --------------------------------------------------------------------------
408
+
409
+ #: 演化巡检项 → 对应的写入动作(`scrub` 已有独立 tick,不并入此项)
410
+ EVOLVE_FIXES = {"ccg_backlog": "consolidate_run",
411
+ "importance_drift": "importance"}
412
+
413
+
414
+ # 生效条件:nodes 与 top 必填,遍历索引条目统计缺 verification_basis 或 has_neg_conditions 的候补数 n、no_basis、no_neg 并取最多 top 个 sample,返回含 proxy=True 与 EVOLVE_FIXES['ccg_backlog'] 的字典。
415
+ def _ccg_backlog(nodes: dict, top: int) -> dict:
416
+ """固化候补量(**索引代理指标**:零读节点文件、确定性、O(N))。
417
+
418
+ 真正的固化闸门在 `consolidate`(四要素 + 验证基底 + 白箱 replay,需读正文与 LLM)。
419
+ 巡检只要**驱动信号**:索引里的 `verification_basis` / `has_neg_conditions` 已能
420
+ 区分「有/无验证基底」「有/无负条件」,足够回答「有没有货等着固化」。
421
+
422
+ 代理指标 ≠ 判定结论:报出的是**候补量**,不是「这些节点确实该固化」。
423
+ """
424
+ n = no_basis = no_neg = 0
425
+ sample = []
426
+ # H-4 止血:快照迭代(同 `_locked_nodes` 注释;N138 裸迭代崩溃面)。
427
+ for nid, e in list(nodes.items()):
428
+ mb = not e.get("verification_basis")
429
+ mn = not e.get("has_neg_conditions")
430
+ no_basis += 1 if mb else 0
431
+ no_neg += 1 if mn else 0
432
+ if mb or mn:
433
+ n += 1
434
+ if len(sample) < top:
435
+ sample.append(nid)
436
+ return {"n": n, "no_basis": no_basis, "no_neg": no_neg, "sample": sample,
437
+ "proxy": True, "fix": EVOLVE_FIXES["ccg_backlog"]}
438
+
439
+
440
+ # 生效条件:cg 必填,取 cg.index['nodes'](layer 非空时按 layer 过滤),经 weights.recalc(apply=False) 与 _ccg_backlog 汇总,返回 ok=True、readonly=True、dry_run=True 的候选盘点字典。
441
+ def evolution_candidates(cg, *, layer: str = None, top: int = 8,
442
+ min_delta: float = None) -> dict:
443
+ """演化候选盘点(G7):**只读、零读节点文件、确定性、不写盘**。
444
+
445
+ 回答「现在有多少该固化 / 该重算重要性的候选」,供常驻巡检与人工决策。
446
+ 与 `diagnose()`(故障体检)刻意分开:这里盘的是**演化工作量**,不是故障——
447
+ 候补多不代表库有毛病,故 `ok` 恒 True、severity 恒 info。
448
+
449
+ `importance_drift` 复用 `weights.recalc(apply=False)`(纯数学,不读文件);
450
+ `ccg_backlog` 用索引代理指标。二者都不碰节点内容。
451
+ """
452
+ nodes = (getattr(cg, "index", None) or {}).get("nodes") or {}
453
+ if layer:
454
+ # H-4 止血:快照迭代(N138 裸迭代崩溃面)——过滤结果另建新 dict,
455
+ # 与旧式字典推导逐项同序同值。
456
+ nodes = {k: v for k, v in list(nodes.items()) if v.get("layer") == layer}
457
+ from . import weights
458
+ md = weights.APPLY_DELTA if min_delta is None else float(min_delta)
459
+ imp = weights.recalc(cg, layer=layer, apply=False, min_delta=md,
460
+ dry_run_samples=top)
461
+ cb = _ccg_backlog(nodes, top)
462
+ idr = {"n": imp["changed"], "scanned": imp["nodes_scanned"],
463
+ "min_delta": imp["min_delta"],
464
+ "sample": [s["node_id"] for s in imp["samples"]],
465
+ "fix": EVOLVE_FIXES["importance_drift"]}
466
+ return {"ok": True, "action": "evolve_check", "op": "sustain",
467
+ "root": cg.root, "t": time.time(), "readonly": True, "dry_run": True,
468
+ "nodes": len(nodes), "top": top, "layer": layer,
469
+ "ccg_backlog": cb, "importance_drift": idr,
470
+ "candidates": cb["n"] + idr["n"],
471
+ "by_fix": {EVOLVE_FIXES["ccg_backlog"]: cb["n"],
472
+ EVOLVE_FIXES["importance_drift"]: idr["n"]},
473
+ "note": ("只读盘点:未写盘、未改任何节点;固化需 LLM(交人工/另批),"
474
+ "重要性重算为确定性动作(有 rollback)")}
475
+
476
+
477
+ # 生效条件:给定 cg 后只读汇总(nodes 取自 cg.index、disk 计数、refindex.check_refs),stale_temp_age 传入 _list_stale_temps;check_heartbeat / check_evolution(evolve_top) / check_provenance(provenance_top) 为真时分别追加对应 issue,返回 ok = 无 severity=="warning" 的 issue 连同 stats。
478
+ def diagnose(cg, *, name: str = "md_cg", stale_temp_age: float = STALE_TEMP_AGE,
479
+ check_heartbeat: bool = True, check_evolution: bool = True,
480
+ evolve_top: int = 5, check_provenance: bool = True,
481
+ provenance_top: int = 5) -> dict:
482
+ """只读体检:返回 issues(带 fix 名)与 stats,不改动任何文件。"""
483
+ root = cg.root
484
+ issues = []
485
+ nodes = (getattr(cg, "index", {}) or {}).get("nodes") or {}
486
+ disk = _count_node_files(root)
487
+
488
+ if len(nodes) != disk:
489
+ issues.append({"code": "index_drift", "severity": "warning",
490
+ "detail": f"索引 {len(nodes)} ≠ 磁盘 {disk}",
491
+ "fix": "rebuild_index"})
492
+ # H-4 止血:快照迭代(N138:前台 add/flush 与后台巡检共用一个 MdCG 实例,
493
+ # 索引 dict 是共享可变面;裸 items() 撞并发写即 RuntimeError)。
494
+ # 面**不止本文件**:本函数默认参数还会经 evolution_candidates → weights.recalc
495
+ # → weights.coverage_index,且下面无条件调 refindex.check_refs——那些站点同样
496
+ # 作用于这个共享 dict,必须一并取快照(否则只切在这里等于没止血;见
497
+ # md_cg/test_h4_sustain_snapshot.py 的全域扫描器与目标级判据)。
498
+ orphans = [nid for nid, e in list(nodes.items())
499
+ if e.get("path")
500
+ and not os.path.exists(os.path.join(root, e["path"]))]
501
+ if orphans:
502
+ issues.append({"code": "index_orphan", "severity": "warning",
503
+ "detail": f"{len(orphans)} 条索引指向不存在的文件",
504
+ "sample": orphans[:5], "fix": "rebuild_index"})
505
+
506
+ shards = _index_log_shards(root)
507
+ if shards:
508
+ # severity 必须是 warning:_tick_heal 只在 rep["ok"] 为假(即存在 warning)时
509
+ # 才进 heal(),标 info 会让本问题永远进不了修复路径。
510
+ issues.append({"code": "index_log_backlog", "severity": "warning",
511
+ "detail": f"{len(shards)} 个索引增量分片未合并",
512
+ "fix": "compact_index"})
513
+
514
+ _acc_log = os.path.join(root, "_access.log")
515
+ _acc_lines = 0
516
+ try:
517
+ if os.path.exists(_acc_log):
518
+ with open(_acc_log, encoding="utf-8", errors="replace") as _af:
519
+ _acc_lines = sum(1 for _ in _af)
520
+ except OSError:
521
+ _acc_lines = 0
522
+ if _acc_lines > ACCESS_LOG_COMPACT_LINES:
523
+ issues.append({"code": "access_log_backlog", "severity": "warning",
524
+ "detail": f"访问日志 {_acc_lines} 行未折叠进节点"
525
+ f"(> {ACCESS_LOG_COMPACT_LINES})",
526
+ "fix": "compact_access"})
527
+
528
+ temps = _list_stale_temps(root, stale_temp_age)
529
+ if temps:
530
+ issues.append({"code": "stale_temps", "severity": "info",
531
+ "detail": f"{len(temps)} 个陈旧临时文件",
532
+ "sample": [os.path.basename(p) for p in temps[:5]],
533
+ "fix": "sweep_temps"})
534
+
535
+ half = _half_line_logs(root)
536
+ if half:
537
+ issues.append({"code": "half_line_logs", "severity": "warning",
538
+ "detail": f"{len(half)} 个日志末尾半截行",
539
+ "sample": [os.path.basename(p) for p in half[:5]],
540
+ "fix": "seal_half_lines"})
541
+
542
+ locked = _locked_nodes(cg)
543
+ if locked:
544
+ issues.append({"code": "locked_nodes", "severity": "info",
545
+ "detail": f"{locked} 个私有节点密文不可解(缺密钥)",
546
+ "fix": None}) # 权限事实,不自愈
547
+
548
+ # ref 漂移 / 悬空(R3):索引是派生物,源变了就报 stale,源没了就报 dangling。
549
+ # 只读、不抛;修复动作是重跑 index_code / index_doc(rebuild_refs)。
550
+ from . import refindex
551
+ refs = refindex.check_refs(cg, ledger=refindex.Ledger(root))
552
+ if refs["stale"]:
553
+ issues.append({"code": "ref_stale", "severity": "warning",
554
+ "detail": f"{len(refs['stale'])} 个 ref 漂移(源文件已改动)",
555
+ "sample": [r.get("path") for r in refs["stale"][:5]],
556
+ "fix": "rebuild_refs"})
557
+ if refs["dangling"]:
558
+ issues.append({"code": "ref_dangling", "severity": "warning",
559
+ "detail": f"{len(refs['dangling'])} 个 ref 悬空(源文件已删除)",
560
+ "sample": [r.get("path") for r in refs["dangling"][:5]],
561
+ # 悬空**没有**自动动作:源已不在,重切只能扫到 0 个文件
562
+ # (refindex.rebuild 的 roots_missing 侧已拦住「水位被写空」),
563
+ # 处置走 op=ref action=prune 或恢复真源后重建 ⇒ 不承诺够不着的
564
+ # 动作(fix 只被展示面消费,改它是口径修正而非行为开关)。
565
+ "fix": None})
566
+ if refs.get("truncated"):
567
+ issues.append({"code": "ref_check_truncated", "severity": "info",
568
+ "detail": f"ref 巡检只覆盖前 {refs['max_nodes']} 个节点,结果不完整",
569
+ "fix": None}) # 覆盖缺口,显式说出来而非静默
570
+
571
+ if check_heartbeat:
572
+ st = read_stamp(name)
573
+ state = (judge(st["age"], task_running=bool(st.get("task_running")))
574
+ if st else "absent")
575
+ if state != "ok":
576
+ issues.append({"code": "heartbeat_" + state, "severity": "info",
577
+ "detail": f"本机心跳状态:{state}", "fix": "beat"})
578
+
579
+ # ---- 演化巡检(G7):盘的是「该做多少事」,不是「库有毛病」,
580
+ # 故 severity 恒 info(不影响 ok),且全程只读、零读节点文件。
581
+ evolve = None
582
+ if check_evolution:
583
+ evolve = evolution_candidates(cg, top=evolve_top)
584
+ cb, idr = evolve["ccg_backlog"], evolve["importance_drift"]
585
+ if cb["n"]:
586
+ issues.append({"code": "ccg_backlog", "severity": "info",
587
+ "detail": (f"{cb['n']} 个固化候补"
588
+ f"(索引代理:无验证基底 {cb['no_basis']}"
589
+ f" / 无负条件 {cb['no_neg']})"),
590
+ "sample": cb["sample"], "fix": cb["fix"],
591
+ "proxy": True})
592
+ if idr["n"]:
593
+ issues.append({"code": "importance_drift", "severity": "info",
594
+ "detail": (f"{idr['n']} 个节点结构重要性偏离 "
595
+ f"≥{idr['min_delta']}(可重算)"),
596
+ "sample": idr["sample"], "fix": idr["fix"]})
597
+
598
+ # ---- 派生溯源巡检(G8):只读检出悬空派生边(端点已不在索引内)。
599
+ # **无自动修复**:删边等于篡改演进血缘,只报告、由人处置;
600
+ # 故 severity 恒 info(不影响 ok、不触发自愈),零读节点文件。
601
+ prov = None
602
+ if check_provenance:
603
+ from . import provenance as _pv
604
+ prov = _pv.check(cg, limit=provenance_top)
605
+ if prov["dangling_count"]:
606
+ issues.append({"code": "provenance_dangling", "severity": "info",
607
+ "detail": (f"{prov['dangling_count']} 条派生边悬空"
608
+ f"({prov['edges']} 条边中,端点不在索引内)"),
609
+ "sample": [f"{r['child']}->{r['parent']}"
610
+ for r in prov["dangling"]],
611
+ "fix": None}) # 关系事实:只检出,不自动删边
612
+
613
+ return {"ok": not any(i["severity"] == "warning" for i in issues),
614
+ "root": root, "issues": issues, "t": time.time(),
615
+ "evolve": evolve, "provenance": prov,
616
+ "stats": {"nodes_indexed": len(nodes), "nodes_on_disk": disk,
617
+ "index_log_shards": len(shards), "stale_temps": len(temps),
618
+ "half_line_logs": len(half), "locked_nodes": locked,
619
+ "ref_checked": refs["checked"],
620
+ "ref_stale": len(refs["stale"]),
621
+ "ref_dangling": len(refs["dangling"]),
622
+ "provenance_edges": (prov["edges"] if prov else 0),
623
+ "provenance_dangling":
624
+ (prov["dangling_count"] if prov else 0),
625
+ "evolve_candidates":
626
+ (evolve["candidates"] if evolve else 0)}}
627
+
628
+
629
+ # --------------------------------------------------------------------------
630
+ # 自愈
631
+ # --------------------------------------------------------------------------
632
+
633
+ # 生效条件:path 必填,以 'ab' 模式向该路径追加写入单个换行 b'\n',无返回值。
634
+ def _seal_half_line(path: str):
635
+ with open(path, "ab") as f:
636
+ f.write(b"\n")
637
+
638
+
639
+ # 生效条件:root/op/action 必填,向 root/SUSTAIN_LOG(模块常量)追加一行含 t/op/action/detail(截断 200)/pid 的 JSONL,无返回值。
640
+ def _audit(root: str, op: str, action: str, detail: str = ""):
641
+ append_jsonl(os.path.join(root, SUSTAIN_LOG),
642
+ {"t": time.time(), "op": op, "action": action,
643
+ "detail": str(detail)[:200], "pid": os.getpid()})
644
+
645
+
646
+ # --------------------------------------------------------------------------
647
+ # 同因不重试(自愈的失败记忆)
648
+ #
649
+ # 为什么需要:自愈没有记忆——每 tick 都 diagnose → heal。当病灶**超出预算**时
650
+ # (例:ref_stale 的根因是 rebuild 被 max_files=500 / max_items=2000 截断,
651
+ # 永远重写不到"坏"的那几个节点、截断还跳过对账),每 tick 都会重跑同一次注定
652
+ # 失败的 rebuild、重刷同一批行与审计。记忆 + 退避是唯一有界的止法。
653
+ #
654
+ # 边界(刻意保守):①只影响**同一信号的重复失败**——首次失败永远真跑、永远如实
655
+ # 上报,绝不把「失败」变成「不报」;②信号变化、或上次结果不坏(ok/truncated 都
656
+ # 好)⇒ 立刻放行重试;③记忆进程内、重启即清(只影响退避节奏,不影响正确性);
657
+ # ④默认只在常驻循环里启用(`repeat_guard=True`),手动 op=sustain action=heal
658
+ # 不受影响(手动即显式要求试一次)。
659
+ # --------------------------------------------------------------------------
660
+
661
+ _HEAL_MEMO: dict = {} # (root, code) → {signal, bad, streak, ts}
662
+ HEAL_BACKOFF_MAX = 3600.0 # 退避上限 1h
663
+
664
+
665
+ # 生效条件:res 非 dict 时返回 False,否则返回 res.get('ok') is False 或 bool(res.get('truncated'))——即「跑完了但没修好/没修完」;
666
+ def _bad_result(res) -> bool:
667
+ """动作结果是否「跑完了但没修好」(ok=False 或 truncated)。"""
668
+ if not isinstance(res, dict):
669
+ return False
670
+ return res.get("ok") is False or bool(res.get("truncated"))
671
+
672
+
673
+ # 生效条件:按 (root, code) 查 _HEAL_MEMO,存在且 signal 与上次相同、上次 bad 且距上次尝试 < min(HEAL_BACKOFF_MAX, interval*2^(streak-1)) 时返回 (True, {'streak','wait_s','age_s','reason'})(不改记忆),否则返回 (False, {});
674
+ def _repeat_skip(root: str, code: str, signal: str,
675
+ interval: float) -> tuple:
676
+ """同因失败不重试:返回 (skip, info)。`interval` 是调用方的巡检间隔(退避基准)。
677
+
678
+ 等待时长只由**失败的尝试次数**(streak)决定:每次「真的又试了一次仍失败」
679
+ 才翻倍;窗口内被拦下的那些 tick 只报同一个窗口,不把等待继续推大——
680
+ 否则一次失败就会在几个 tick 内冲到 1h 上限,退避与「试了几次」脱钩。
681
+ """
682
+ st = _HEAL_MEMO.get((root, code)) or {}
683
+ if not st or st.get("signal") != signal or not st.get("bad"):
684
+ return False, {}
685
+ streak = int(st.get("streak") or 0)
686
+ wait = min(HEAL_BACKOFF_MAX, max(0.0, float(interval)) * (2 ** max(0, streak - 1)))
687
+ age = time.time() - float(st.get("ts") or 0.0)
688
+ if age < wait:
689
+ return True, {"streak": streak, "wait_s": round(wait, 1),
690
+ "age_s": round(age, 1), "reason": "repeat_failure"}
691
+ return False, {}
692
+
693
+
694
+ # 生效条件:按 (root, code) 记下本次的 signal 与结果是否坏;同信号连续失败时 streak 累加、坏结果首次记 1、好结果清零,ts 记当前时间;
695
+ def _repeat_remember(root: str, code: str, signal: str, bad: bool) -> None:
696
+ st = _HEAL_MEMO.get((root, code)) or {}
697
+ same = st.get("signal") == signal
698
+ if bad:
699
+ streak = int(st.get("streak") or 0) + 1 if same else 1
700
+ else:
701
+ streak = 0
702
+ _HEAL_MEMO[(root, code)] = {"signal": signal, "bad": bool(bad),
703
+ "streak": streak, "ts": time.time()}
704
+
705
+
706
+ # 生效条件:按 diagnose(cg, name=name, stale_temp_age=stale_temp_age) 的 issues code 集合分派——命中 index_drift/index_orphan 重建索引、**ref_stale** 按 ref 重建源索引(ref_dangling 不触发)、index_log_backlog 合并索引分片、stale_temps 清理陈旧临时文件、half_line_logs 修补半截日志;ccg_backlog 仅 allow_evolve=True 且 reflect_fn 非 None 时才 consolidate(否则记 needs_llm),importance_drift 仅 allow_evolve=True 时才重算重要性(否则记 evolve_disabled);dry_run=True 时各动作只记入 actions 不落盘,返回含 after["ok"]、dry_run、actions、before/after 的 stats 与 t 的 dict;repeat_guard 为真时同一信号且上次未修好的动作记 {'applied': False, 'reason': 'repeat_failure'} 并按 heal_interval*2^n 退避(上限 1h);
707
+ def heal(cg, *, name: str = "md_cg", dry_run: bool = False,
708
+ stale_temp_age: float = STALE_TEMP_AGE,
709
+ allow_evolve: bool = False, reflect_fn=None, verify_fn=None,
710
+ heal_interval: float = DEFAULT_HEAL_INTERVAL,
711
+ repeat_guard: bool = False) -> dict:
712
+ """按诊断结果修复派生物。dry_run=True 时只列动作、不落盘。
713
+
714
+ 演化类动作(G7)默认**不动**,须显式 `allow_evolve=True` 才放行,且只放行
715
+ **确定性**动作(重要性重算,有 rollback);依赖 LLM 的固化永不自动跑。
716
+
717
+ `repeat_guard=True`(常驻循环用)开启「同因不重试」:诊断信号与上次全同、
718
+ 且上次结果未修好(`ok=False` 或 `truncated`)时不再执行,记
719
+ `{'applied': False, 'reason': 'repeat_failure', 'streak': n}` 并按
720
+ `heal_interval × 2^n` 退避(上限 1h,进程内记忆、重启即清)。首次失败永远
721
+ 真跑并如实上报;手动调用默认不开启(手动即显式要求试一次)。
722
+ """
723
+ before = diagnose(cg, name=name, stale_temp_age=stale_temp_age)
724
+ codes = {i["code"] for i in before["issues"]}
725
+ root = cg.root
726
+ actions = []
727
+
728
+ def _signal(code: str) -> str:
729
+ """该 code 的**诊断事实签名**(detail + sample):同因=信号不变。"""
730
+ for i in before["issues"]:
731
+ if i["code"] == code:
732
+ return "%s|%s" % (i.get("detail"), i.get("sample"))
733
+ return ""
734
+
735
+ # 生效条件:闭包 dry_run 为真时向 actions 追加 {"code": code, "detail": detail, "applied": False} 并返回;repeat_guard 为真且 _repeat_skip 判为重复失败时追加 applied=False/reason=repeat_failure/streak/wait_s 并返回;否则调用 fn() 取回值 res,res 为 dict 且 ok 为 False(或 truncated)时记 ok=False 并附 result 摘要,抛异常时追加 applied=True/ok=False 与 error,最后执行 _audit(root, "heal", code, detail) 并 _repeat_remember;
736
+ def act(code: str, detail: str, fn):
737
+ if dry_run:
738
+ actions.append({"code": code, "detail": detail, "applied": False})
739
+ return
740
+ if repeat_guard:
741
+ skip, info = _repeat_skip(root, code, _signal(code), heal_interval)
742
+ if skip:
743
+ # 不是「不报」:把「同一病灶上次就没修好」如实记进动作与审计。
744
+ actions.append({"code": code, "detail": detail, "applied": False,
745
+ "ok": False, **info})
746
+ _audit(root, "heal", code,
747
+ "%s → repeat_failure(第 %s 次,退避 %ss)"
748
+ % (detail, info.get("streak"), info.get("wait_s")))
749
+ return
750
+ res = None
751
+ try:
752
+ res = fn()
753
+ except Exception as e: # 自愈失败不能拖垮进程
754
+ actions.append({"code": code, "detail": detail, "applied": True,
755
+ "ok": False, "error": f"{type(e).__name__}: {e}"})
756
+ else:
757
+ # `fn()` 跑完 ≠ 修好:返回 dict 且 ok is False(或 truncated)时
758
+ # 如实记 ok=False——「重建失败」绝不能被记成 ok=True。
759
+ bad = _bad_result(res)
760
+ row = {"code": code, "detail": detail, "applied": True, "ok": not bad}
761
+ if isinstance(res, dict):
762
+ brief = {k: res[k] for k in ("ok", "indexed", "files", "truncated",
763
+ "roots_missing", "errors")
764
+ if k in res}
765
+ if isinstance(brief.get("errors"), list):
766
+ brief["errors"] = brief["errors"][:1]
767
+ if brief:
768
+ row["result"] = brief
769
+ detail = "%s → %s" % (detail, brief)
770
+ actions.append(row)
771
+ if repeat_guard:
772
+ _repeat_remember(root, code, _signal(code),
773
+ bool(actions[-1].get("ok") is False))
774
+ _audit(root, "heal", code, detail)
775
+
776
+ if "index_drift" in codes or "index_orphan" in codes:
777
+ act("rebuild_index", "重建索引(漂移 / 孤儿)", cg.rebuild_index)
778
+ if "ref_stale" in codes:
779
+ # 只对 ref_stale 自动重建。ref_dangling(源已删/已搬)重切只会 0 文件、
780
+ # 治不好 dangling(rebuild 的 roots_missing 侧已拦「水位被写空」),出口是
781
+ # op=ref action=prune / 恢复真源后重建 —— 不在这里触发。
782
+ # 止血点必须在本行:heal 按 code 分派(fix 字段无任何调度器消费)。
783
+ from . import refindex as _ri
784
+ _led = _ri.Ledger(root)
785
+ act("rebuild_refs", "按 ref 重建源索引(修复 ref_stale)",
786
+ lambda: _ri.rebuild(cg, ledger=_led))
787
+ if "index_log_backlog" in codes:
788
+ act("flush_index", "合并索引增量分片", cg.flush)
789
+
790
+ # 生效条件:闭包内先 cg.flush()(把内存 _dirty 落进分片)再 cg.compact_index()
791
+ # (读「_index.json 快照 + 分片」写回快照并清空分片);顺序不可颠倒——compact_index
792
+ # 以整体替换 self.index,未 flush 的内存条目会丢。
793
+ def _compact_index():
794
+ cg.flush()
795
+ return cg.compact_index()
796
+ if "index_log_backlog" in codes:
797
+ act("compact_index", "分片折叠进 _index.json(快照落盘)", _compact_index)
798
+ if "access_log_backlog" in codes:
799
+ act("compact_access", "折叠访问日志进 access_count / last_access",
800
+ cg.compact_access)
801
+ if "stale_temps" in codes:
802
+ paths = _list_stale_temps(root, stale_temp_age)
803
+
804
+ # 生效条件:对闭包变量 paths 中每个路径尝试 os.remove(p),单个路径的 OSError 被吞掉后继续处理后续路径;
805
+ def _sweep():
806
+ for p in paths:
807
+ try:
808
+ os.remove(p)
809
+ except OSError:
810
+ pass
811
+ act("sweep_temps", f"清理 {len(paths)} 个陈旧临时文件", _sweep)
812
+ if "half_line_logs" in codes:
813
+ paths = _half_line_logs(root)
814
+ act("seal_half_lines", f"修补 {len(paths)} 个半截日志行",
815
+ lambda: [_seal_half_line(p) for p in paths])
816
+
817
+ # ---- 演化类修复(G7):默认关闭,且只放行确定性动作 ----
818
+ if "ccg_backlog" in codes:
819
+ det = next((i for i in before["issues"] if i["code"] == "ccg_backlog"), {})
820
+ detail = f"{det.get('detail', '固化候补')};固化需 LLM 反思/验证"
821
+ if allow_evolve and reflect_fn is not None:
822
+ from . import consolidate as _cd
823
+
824
+ # 生效条件:调用时返回 _cd.consolidate(cg.root, apply=True, reflect_fn=reflect_fn, verify_fn=verify_fn);
825
+ def _consolidate():
826
+ return _cd.consolidate(cg.root, apply=True,
827
+ reflect_fn=reflect_fn,
828
+ verify_fn=verify_fn)
829
+ act("consolidate_run", detail, _consolidate)
830
+ else:
831
+ actions.append({"code": "consolidate_run", "detail": detail,
832
+ "applied": False, "reason": "needs_llm"})
833
+ if "importance_drift" in codes:
834
+ if allow_evolve:
835
+ from . import weights
836
+
837
+ # 生效条件:调用时返回 weights.recalc(cg, apply=True, actor="sustain_evolve");
838
+ def _importance():
839
+ return weights.recalc(cg, apply=True, actor="sustain_evolve")
840
+ act("importance", "重算结构重要性(确定性;可 rollback)", _importance)
841
+ else:
842
+ actions.append({"code": "importance",
843
+ "detail": "重算结构重要性(确定性动作)",
844
+ "applied": False, "reason": "evolve_disabled"})
845
+
846
+ after = (before if dry_run
847
+ else diagnose(cg, name=name, stale_temp_age=stale_temp_age))
848
+ return {"ok": after["ok"], "dry_run": dry_run, "actions": actions,
849
+ "before": before["stats"], "after": after["stats"],
850
+ "t": time.time()}
851
+
852
+
853
+ # --------------------------------------------------------------------------
854
+ # 会话水位(重启续接)
855
+ # --------------------------------------------------------------------------
856
+
857
+ # 生效条件:以 root 实例化后账本固定指向 os.path.join(root, LEDGER_FILE),其 _load/_save/note/resume_point/sessions/summary 均以该路径为唯一读写对象;
858
+ class SessionLedger:
859
+ """会话水位账本:<root>/_sessions.json。
860
+
861
+ 与 sources.Ingestor 的水位互补:Ingestor 记「某个源读到哪」,
862
+ 这里记「某个会话发生过什么」——重启后按会话给出续接点。
863
+ """
864
+
865
+ # 生效条件:传入 root 时置 self.root=root 且 self.path=os.path.join(root, LEDGER_FILE),不做其他校验;
866
+ def __init__(self, root: str):
867
+ self.root = root
868
+ self.path = os.path.join(root, LEDGER_FILE)
869
+
870
+ # 生效条件:self.path(root/LEDGER_FILE)可读且 json 结果为 dict、且 d.get("sessions") 也是 dict 时返回该 d;self.path 不可读、json.load 抛 ValueError/OSError、或 d 非 dict / sessions 非 dict 时返回 {"schema": 1, "sessions": {}};
871
+ def _load(self):
872
+ if os.path.exists(self.path):
873
+ try:
874
+ with open(self.path, encoding="utf-8") as f:
875
+ d = json.load(f)
876
+ if isinstance(d, dict) and isinstance(d.get("sessions"), dict):
877
+ return d
878
+ except (ValueError, OSError):
879
+ pass
880
+ return {"schema": 1, "sessions": {}}
881
+
882
+ # 生效条件:传入 d 时执行 atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1)),自身无返回值;
883
+ def _save(self, d):
884
+ atomic_write(self.path, json.dumps(d, ensure_ascii=False, indent=1))
885
+
886
+ # 生效条件:以 session 为键 setdefault 会话记录后写 events=int(s.get("events") or 0)+int(n)(n 默认 1,n=0 时计数不变),t 非 None 时写 last_t 且 first_t 为空时一并写入,seq 非 None 时写 last_seq,actor 为真值时写 actor,最后写 updated_at 并 _save,返回 dict(s, session=session);
887
+ def note(self, session: str, *, t=None, seq=None, n: int = 1,
888
+ actor: str = None) -> dict:
889
+ """记一笔会话活动(事件计数 + 最后 (t, seq) 水位)。"""
890
+ d = self._load()
891
+ s = d["sessions"].setdefault(
892
+ session, {"events": 0, "first_t": None, "last_t": None})
893
+ s["events"] = int(s.get("events") or 0) + int(n)
894
+ if t is not None:
895
+ s["last_t"] = float(t)
896
+ if s.get("first_t") is None:
897
+ s["first_t"] = float(t)
898
+ if seq is not None:
899
+ s["last_seq"] = seq
900
+ if actor:
901
+ s["actor"] = actor
902
+ s["updated_at"] = time.time()
903
+ self._save(d)
904
+ return dict(s, session=session)
905
+
906
+ # 生效条件:_load()["sessions"].get(session) 为假值(键缺失或值为空 dict)时返回 {"session": session, "resume": None, "events": 0};否则返回 {"session": session, "events": s.get("events", 0), "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")}, "last_t": s.get("last_t"), "actor": s.get("actor")};
907
+ def resume_point(self, session: str) -> dict:
908
+ """重启续接点:(t, seq) 之后的事件才是新的。"""
909
+ s = self._load()["sessions"].get(session)
910
+ if not s:
911
+ return {"session": session, "resume": None, "events": 0}
912
+ return {"session": session, "events": s.get("events", 0),
913
+ "resume": {"t": s.get("last_t"), "seq": s.get("last_seq")},
914
+ "last_t": s.get("last_t"), "actor": s.get("actor")}
915
+
916
+ # 生效条件:无参调用时返回 dict(self._load()["sessions"]) 的浅拷贝,账本缺失/损坏时 _load 回落默认值故此处为 {};
917
+ def sessions(self) -> dict:
918
+ return dict(self._load()["sessions"])
919
+
920
+ # 生效条件:取 d=_load()["sessions"] 后返回 {"sessions": len(d), "events": sum(int(v.get("events") or 0)), "latest": max(v.get("last_t") or 0) if d else None},d 为空时 latest 为 None;
921
+ def summary(self) -> dict:
922
+ d = self._load()["sessions"]
923
+ return {"sessions": len(d),
924
+ "events": sum(int(v.get("events") or 0) for v in d.values()),
925
+ "latest": max((v.get("last_t") or 0) for v in d.values())
926
+ if d else None}
927
+
928
+
929
+ # 生效条件:os.path.exists(os.path.join(cg.root, "_sources.json")) 为真且 json.load 成功时返回 dict((d or {}).get("sources") or {})(d 或 sources 为假值即回落 {});该路径不可读或 json.load 抛 ValueError/OSError 时返回 {};
930
+ def watermarks(cg) -> dict:
931
+ """源视角水位快照(读 sources.Ingestor 的 _sources.json)。"""
932
+ p = os.path.join(cg.root, "_sources.json")
933
+ if not os.path.exists(p):
934
+ return {}
935
+ try:
936
+ with open(p, encoding="utf-8") as f:
937
+ d = json.load(f)
938
+ except (ValueError, OSError):
939
+ return {}
940
+ return dict((d or {}).get("sources") or {})
941
+
942
+
943
+ # --------------------------------------------------------------------------
944
+ # 常驻循环
945
+ # --------------------------------------------------------------------------
946
+
947
+ # 生效条件:传入 cg 即构造实例并把 self.cg 指向它,name/beat_interval/heal_interval/auto_heal/scrub_interval/auto_scrub/evolve_interval/auto_evolve/tidy_interval/auto_tidy 用各默认值(DEFAULT_* 与 False/True)经 float()/bool() 落为 self 属性,ledger 为假值(默认 None)时回落 SessionLedger(cg.root),d 经 net_dir(d) 赋值,其余运行态字段初始化为 False/None/空列表/空 Event/Lock
948
+ class SustainLoop:
949
+ """常驻自维持循环:后台线程周期心跳 + 周期巡检 + 必要时自愈。
950
+
951
+ daemon 线程,进程退出即消失;`stop()` 会清掉自己的心跳戳,
952
+ 让对端立刻看到「正常下线」而不是「失联」。
953
+ """
954
+
955
+ # 生效条件:传入 cg 时按 name 与各 DEFAULT_* 默认值初始化——self.d=net_dir(d)(d 假值时回落 MDCG_SUSTAIN_DIR/~/ .mdcg/sustain)、self.ledger=ledger or SessionLedger(cg.root)(ledger 假值时新建),beat/heal/scrub/evolve/tidy 间隔 float() 化、auto_heal/auto_scrub/auto_evolve/auto_tidy bool() 化后存为实例属性;
956
+ def __init__(self, cg, name: str = "md_cg", *,
957
+ beat_interval: float = DEFAULT_BEAT_INTERVAL,
958
+ heal_interval: float = DEFAULT_HEAL_INTERVAL,
959
+ auto_heal: bool = True, d: str = None,
960
+ ledger: SessionLedger = None,
961
+ scrub_interval: float = DEFAULT_SCRUB_INTERVAL,
962
+ auto_scrub: bool = False,
963
+ evolve_interval: float = DEFAULT_EVOLVE_INTERVAL,
964
+ auto_evolve: bool = False,
965
+ tidy_interval: float = DEFAULT_TIDY_INTERVAL,
966
+ auto_tidy: bool = False):
967
+ self.cg = cg
968
+ self.name = name
969
+ self.beat_interval = float(beat_interval)
970
+ self.heal_interval = float(heal_interval)
971
+ self.auto_heal = bool(auto_heal)
972
+ self.d = net_dir(d)
973
+ self.ledger = ledger or SessionLedger(cg.root)
974
+ self.scrub_interval = float(scrub_interval)
975
+ self.auto_scrub = bool(auto_scrub)
976
+ self.evolve_interval = float(evolve_interval)
977
+ self.auto_evolve = bool(auto_evolve)
978
+ self.tidy_interval = float(tidy_interval)
979
+ self.auto_tidy = bool(auto_tidy)
980
+ self.task_running = False
981
+ self.beats = 0
982
+ self.last_beat = None
983
+ self.last_diagnose = None
984
+ self.heals = []
985
+ self.last_scrub = None
986
+ self.scrubs = []
987
+ self.last_evolve = None
988
+ self.evolves = []
989
+ self.last_tidy = None
990
+ self.tidys = []
991
+ # 数据健康不变量断言集结论(Pi⑤):与 tidy 同节奏,只取结论不落盘
992
+ self.last_conformance = None
993
+ self._started_at = None
994
+ self._th = None
995
+ self._stop = threading.Event()
996
+ self._lock = threading.Lock()
997
+
998
+ # ---- 心跳 ----
999
+
1000
+ # 生效条件:task_running 非 None 时先置 self.task_running=bool(task_running),再 write_stamp(self.name, self.d, task_running=self.task_running, root=self.cg.root, uptime=... if self._started_at else 0.0),beats 自增并记 last_beat=rec["ts"],返回该 rec;
1001
+ def beat(self, task_running: bool = None) -> dict:
1002
+ if task_running is not None:
1003
+ self.task_running = bool(task_running)
1004
+ rec = write_stamp(
1005
+ self.name, self.d, task_running=self.task_running, root=self.cg.root,
1006
+ uptime=round(time.time() - self._started_at, 3)
1007
+ if self._started_at else 0.0)
1008
+ self.beats += 1
1009
+ self.last_beat = rec["ts"]
1010
+ return rec
1011
+
1012
+ # ---- 生命周期 ----
1013
+
1014
+ # 生效条件:不适用(无必需形参与模块级常量)
1015
+ def start(self):
1016
+ if self._th is not None and self._th.is_alive():
1017
+ return self
1018
+ self._started_at = time.time()
1019
+ self._stop.clear()
1020
+ self.beat()
1021
+ self._th = threading.Thread(target=self._run, name="mdcg-sustain",
1022
+ daemon=True)
1023
+ self._th.start()
1024
+ return self
1025
+
1026
+ # 生效条件:调用时置 _stop 事件,self._th 非 None 时 join(timeout)(默认 3.0)后置为 None,随后 clear_stamp(self.name, self.d),返回 self;
1027
+ def stop(self, timeout: float = 3.0):
1028
+ self._stop.set()
1029
+ if self._th is not None:
1030
+ self._th.join(timeout)
1031
+ self._th = None
1032
+ clear_stamp(self.name, self.d)
1033
+ return self
1034
+
1035
+ # 生效条件:self._stop 未置位期间轮询,按 beat_interval/heal_interval/scrub_interval/evolve_interval/tidy_interval 到期分别执行 beat 与 _tick_heal/_tick_scrub/_tick_evolve/_tick_tidy,各 tick 抛出的异常被吞掉不中断循环,末尾以 _stop.wait(_POLL) 休眠;
1036
+ def _run(self):
1037
+ next_beat = time.time() + self.beat_interval
1038
+ next_heal = time.time() + self.heal_interval
1039
+ next_scrub = time.time() + self.scrub_interval
1040
+ next_evolve = time.time() + self.evolve_interval
1041
+ next_tidy = time.time() + self.tidy_interval
1042
+ while not self._stop.is_set():
1043
+ now = time.time()
1044
+ if now >= next_beat:
1045
+ try:
1046
+ self.beat()
1047
+ except Exception:
1048
+ pass # 心跳失败不中断常驻
1049
+ next_beat = now + self.beat_interval
1050
+ if now >= next_heal:
1051
+ try:
1052
+ self._tick_heal()
1053
+ except Exception:
1054
+ pass # 巡检失败不中断常驻
1055
+ next_heal = now + self.heal_interval
1056
+ if now >= next_scrub:
1057
+ try:
1058
+ self._tick_scrub()
1059
+ except Exception:
1060
+ pass # 自净失败不中断常驻
1061
+ next_scrub = now + self.scrub_interval
1062
+ if now >= next_evolve:
1063
+ try:
1064
+ self._tick_evolve()
1065
+ except Exception:
1066
+ pass # 演化巡检失败不中断常驻
1067
+ next_evolve = now + self.evolve_interval
1068
+ if now >= next_tidy:
1069
+ try:
1070
+ self._tick_tidy()
1071
+ except Exception:
1072
+ pass # 整理巡检失败不中断常驻
1073
+ next_tidy = now + self.tidy_interval
1074
+ self._stop.wait(_POLL)
1075
+
1076
+ # 生效条件:以 apply=self.auto_tidy 调 writelimit.tidy_contextual(self.cg, actor="sustain_tidy"),把 t/scanned/groups/members/applied_count/auto_tidy 记入 self.last_tidy 与 tidys(仅保留最近 20 条),随后调 _tick_conformance();auto_tidy=False(默认)时只盘点不落盘;
1077
+ def _tick_tidy(self):
1078
+ """整理巡检(contextual 流水治理·读侧):同构组聚合 + 成员降权。
1079
+
1080
+ 确定性动作、永不删节点;`auto_tidy=False`(默认)只盘点不落盘。
1081
+ 治理对象:单日批次流水(「批次247收官记忆」×163 那类同模板写入)
1082
+ —— 写入侧限流(writelimit.check)拦增量,本巡检收敛存量。
1083
+ """
1084
+ from . import writelimit
1085
+ r = writelimit.tidy_contextual(self.cg, apply=self.auto_tidy,
1086
+ actor="sustain_tidy")
1087
+ rec = {"t": r["t"], "scanned": r["scanned"], "groups": r["groups"],
1088
+ "members": r["members"],
1089
+ "applied_count": r.get("applied_count", 0),
1090
+ "auto_tidy": self.auto_tidy}
1091
+ self.last_tidy = rec
1092
+ with self._lock:
1093
+ self.tidys.append(rec)
1094
+ self.tidys = self.tidys[-20:]
1095
+ self._tick_conformance()
1096
+
1097
+ # 生效条件:把 conformance.report_summary(self.cg) 的结论写入 self.last_conformance;该调用抛异常时写入 {"ok": False, "verdict": "BLINDSPOT", "error": "<类型名>: <消息>"};
1098
+ def _tick_conformance(self):
1099
+ """数据健康不变量断言集(Pi⑤)——与 tidy 同节奏,**只取结论**。
1100
+
1101
+ 复用 tidy 的 6h 节奏而不新开周期:两者都是「存量数据体检」,且都是
1102
+ 只读巡检。纪律:本断言集**只告警不改数据**,故 `auto_*` 在此无意义;
1103
+ `check_paths=False` 跳过 1.1 万次 stat,`_audit.jsonl` 只读尾窗。
1104
+ """
1105
+ from . import conformance
1106
+ try:
1107
+ self.last_conformance = conformance.report_summary(self.cg)
1108
+ except Exception as e: # noqa: BLE001
1109
+ self.last_conformance = {"ok": False, "verdict": "BLINDSPOT",
1110
+ "error": f"{type(e).__name__}: {e}"}
1111
+
1112
+ # 生效条件:恒以 evolution_candidates(self.cg) 只读盘点并记入 last_evolve 与 evolves(保留最近 20 条);仅当 self.auto_evolve 为真且 ev["importance_drift"]["n"] 为真时才额外执行 weights.recalc(self.cg, apply=True, actor="sustain_evolve"),其异常写入 rec["applied"];
1113
+ def _tick_evolve(self):
1114
+ """演化巡检(G7):盘点固化/重要性候选 —— 让「有能力」变成「有驱动」。
1115
+
1116
+ 恒只读盘点并记账;`auto_evolve=True` 时才额外落盘**确定性**动作
1117
+ (仅重要性重算,可 rollback)。固化依赖 LLM,巡检只报候补量、交人工。
1118
+ """
1119
+ ev = evolution_candidates(self.cg)
1120
+ rec = {"t": ev["t"], "candidates": ev["candidates"],
1121
+ "ccg_backlog": ev["ccg_backlog"]["n"],
1122
+ "importance_drift": ev["importance_drift"]["n"],
1123
+ "auto_evolve": self.auto_evolve, "applied": []}
1124
+ if self.auto_evolve and ev["importance_drift"]["n"]:
1125
+ from . import weights
1126
+ try:
1127
+ r = weights.recalc(self.cg, apply=True, actor="sustain_evolve")
1128
+ rec["applied"].append({"fix": "importance",
1129
+ "written": r["written"],
1130
+ "batch": r["batch"]})
1131
+ except Exception as e: # 演化失败不能拖垮常驻
1132
+ rec["applied"].append({"fix": "importance",
1133
+ "error": f"{type(e).__name__}: {e}"})
1134
+ self.last_evolve = rec
1135
+ with self._lock:
1136
+ self.evolves.append(rec)
1137
+ self.evolves = self.evolves[-20:]
1138
+
1139
+ # 生效条件:以 dry_run=not self.auto_scrub 调 scrub.sweep(self.cg),把 t/ok/n_issues/n_high_medium/applied/dry_run 记入 last_scrub 与 scrubs(保留最近 20 条);auto_scrub=False(默认)时 dry_run=True 只读巡检;
1140
+ def _tick_scrub(self):
1141
+ """记忆自净:抽查 → 联想 → 去污染 → 校准偏差。
1142
+
1143
+ `auto_scrub=False`(默认)时只做只读巡检并记账,不动任何节点;
1144
+ 开启后才执行去污染(仍只做可逆动作、永不删节点)。
1145
+ """
1146
+ from . import scrub
1147
+ rep = scrub.sweep(self.cg, dry_run=not self.auto_scrub)
1148
+ rec = {"t": rep["t"], "ok": rep["ok"],
1149
+ "n_issues": rep["audit"]["n_issues"],
1150
+ "n_high_medium": rep["n_high_medium"],
1151
+ "applied": rep["decontaminate"]["applied"],
1152
+ "dry_run": rep["dry_run"]}
1153
+ self.last_scrub = rec
1154
+ with self._lock:
1155
+ self.scrubs.append(rec)
1156
+ self.scrubs = self.scrubs[-20:]
1157
+
1158
+ # 生效条件:先 diagnose(self.cg, name=self.name) 并将 ok 与 issues code 记入 last_diagnose;仅当 self.auto_heal 为真且 rep["ok"] 为假时才调 heal(self.cg, name=self.name),其 actions 非空时把各 action 的 code 记入 heals(保留最近 20 条);
1159
+ def _tick_heal(self):
1160
+ rep = diagnose(self.cg, name=self.name)
1161
+ self.last_diagnose = {"t": time.time(), "ok": rep["ok"],
1162
+ "issues": [i["code"] for i in rep["issues"]]}
1163
+ if not (self.auto_heal and not rep["ok"]):
1164
+ return
1165
+ # 常驻循环才开 repeat_guard(同因不重试 + 退避):手动 op=sustain action=heal
1166
+ # 不受影响;heal_interval 用作退避基准。
1167
+ res = heal(self.cg, name=self.name,
1168
+ heal_interval=self.heal_interval, repeat_guard=True)
1169
+ if res["actions"]:
1170
+ with self._lock:
1171
+ self.heals.append({
1172
+ "t": res["t"],
1173
+ "actions": [a["code"] for a in res["actions"]],
1174
+ # 没修好的(含重复失败被拦下的)单列,免得「动作跑了」被读成
1175
+ # 「修好了」——审计行同口径。
1176
+ "failed": [a["code"] for a in res["actions"]
1177
+ if a.get("ok") is False]})
1178
+ self.heals = self.heals[-20:]
1179
+
1180
+ # ---- 状态 ----
1181
+
1182
+ # 生效条件:以 read_stamp(self.name, self.d) 判定 state(有戳走 judge(age, interval=self.beat_interval, task_running=...),无戳为 "stopped"),返回含 name/running/pid/uptime(无 _started_at 时为 0.0)/beats/last_beat/各 interval 与 auto_* 开关/heals[-5:]/evolves[-5:]/tidys[-5:]/peers(self.d)/ledger.summary() 的 dict;
1183
+ def status(self) -> dict:
1184
+ st = read_stamp(self.name, self.d)
1185
+ state = (judge(st["age"], interval=self.beat_interval,
1186
+ task_running=bool(st.get("task_running")))
1187
+ if st else "stopped")
1188
+ return {"name": self.name, "running": bool(self._th
1189
+ and self._th.is_alive()),
1190
+ "pid": os.getpid(),
1191
+ "uptime": round(time.time() - self._started_at, 3)
1192
+ if self._started_at else 0.0,
1193
+ "beats": self.beats, "last_beat": self.last_beat,
1194
+ "beat_interval": self.beat_interval,
1195
+ "heal_interval": self.heal_interval,
1196
+ "auto_heal": self.auto_heal, "state": state,
1197
+ "task_running": self.task_running,
1198
+ "last_diagnose": self.last_diagnose,
1199
+ "heals": self.heals[-5:],
1200
+ "scrub_interval": self.scrub_interval,
1201
+ "auto_scrub": self.auto_scrub,
1202
+ "last_scrub": self.last_scrub,
1203
+ "evolve_interval": self.evolve_interval,
1204
+ "auto_evolve": self.auto_evolve,
1205
+ "last_evolve": self.last_evolve,
1206
+ "evolves": self.evolves[-5:],
1207
+ "last_tidy": self.last_tidy,
1208
+ "tidys": self.tidys[-5:],
1209
+ "last_conformance": self.last_conformance,
1210
+ "peers": peers(self.d),
1211
+ "sessions": self.ledger.summary()}
1212
+
1213
+
1214
+ _LOOPS = {}
1215
+ _LOOPS_LOCK = threading.Lock()
1216
+
1217
+
1218
+ # 生效条件:cg 必填,以 (os.path.abspath(cg.root), name) 为键在模块级 _LOOPS 中复用已有实例或新建 SustainLoop 并返回该实例。
1219
+ def ensure_loop(cg, name: str = "md_cg", **kw) -> SustainLoop:
1220
+ """按 (root, name) 复用同一个常驻循环(避免重复线程 / 重复戳)。"""
1221
+ key = (os.path.abspath(cg.root), name)
1222
+ with _LOOPS_LOCK:
1223
+ lp = _LOOPS.get(key)
1224
+ if lp is None:
1225
+ lp = SustainLoop(cg, name=name, **kw)
1226
+ _LOOPS[key] = lp
1227
+ return lp
1228
+
1229
+
1230
+ # 生效条件:cg 必填,返回 _LOOPS[(os.path.abspath(cg.root), name)],该键未登记时返回 None。
1231
+ def get_loop(cg, name: str = "md_cg"):
1232
+ return _LOOPS.get((os.path.abspath(cg.root), name))
1233
+
1234
+
1235
+ # 生效条件:模块级 _LOOPS 非空时清空该表并对每个循环调用 stop()(异常被吞),无返回值。
1236
+ def stop_all():
1237
+ """测试 / 进程退出用:停掉本进程创建的所有常驻循环。"""
1238
+ with _LOOPS_LOCK:
1239
+ loops = list(_LOOPS.values())
1240
+ _LOOPS.clear()
1241
+ for lp in loops:
1242
+ try:
1243
+ lp.stop()
1244
+ except Exception:
1245
+ pass
1246
+
1247
+
1248
+ # 生效条件:以 read_stamp(name) 有戳时返回 heartbeat={'age': round(st["age"],1), 'state': judge(...), 'pid', 'task_running'}、无戳时 {'state':'absent'},loop 取 get_loop(cg, name) 有值时给 running/beats/state、无值时 {'running': False},并附带 SessionLedger(cg.root).summary()、scrub_summary(cg)、evolve_summary(cg)、provenance_summary(cg);
1249
+ def summary(cg, name: str = "md_cg") -> dict:
1250
+ """并入 health_os 的轻量摘要(只读戳与计数,不做巡检)。"""
1251
+ st = read_stamp(name)
1252
+ lp = get_loop(cg, name)
1253
+ return {
1254
+ "heartbeat": ({"age": round(st["age"], 1),
1255
+ "state": judge(st["age"],
1256
+ task_running=bool(st.get("task_running"))),
1257
+ "pid": st.get("pid"),
1258
+ "task_running": bool(st.get("task_running"))}
1259
+ if st else {"state": "absent"}),
1260
+ "loop": ({"running": bool(lp._th and lp._th.is_alive()),
1261
+ "beats": lp.beats, "state": lp.status()["state"]}
1262
+ if lp else {"running": False}),
1263
+ "sessions": SessionLedger(cg.root).summary(),
1264
+ "scrub": scrub_summary(cg),
1265
+ "evolve": evolve_summary(cg),
1266
+ "provenance": provenance_summary(cg),
1267
+ }
1268
+
1269
+
1270
+ # 生效条件:cg 必填,可导入 provenance 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1271
+ def provenance_summary(cg) -> dict:
1272
+ """派生溯源摘要(G8,只读;失败不抛,避免拖垮 health_os)。"""
1273
+ try:
1274
+ from . import provenance as _pv
1275
+ return _pv.summary(cg)
1276
+ except Exception: # noqa: BLE001
1277
+ return {}
1278
+
1279
+
1280
+ # 生效条件:cg 必填,evolution_candidates(cg, top=3) 成功时返回 candidates/ccg_backlog/importance_drift 计数摘要,异常时返回 {}。
1281
+ def evolve_summary(cg) -> dict:
1282
+ """演化巡检摘要(只读;失败不抛,避免拖垮 health_os)。"""
1283
+ try:
1284
+ ev = evolution_candidates(cg, top=3)
1285
+ return {"candidates": ev["candidates"],
1286
+ "ccg_backlog": ev["ccg_backlog"]["n"],
1287
+ "importance_drift": ev["importance_drift"]["n"],
1288
+ "proxy": True}
1289
+ except Exception: # noqa: BLE001
1290
+ return {}
1291
+
1292
+
1293
+ # 生效条件:cg 必填,可导入 scrub 且其 summary(cg) 调用成功时返回该摘要,异常时返回 {}。
1294
+ def scrub_summary(cg) -> dict:
1295
+ """记忆自净摘要(惰性导入,避免模块加载顺序耦合)。"""
1296
+ try:
1297
+ from . import scrub
1298
+ return scrub.summary(cg)
1299
+ except Exception: # noqa: BLE001
1169
1300
  return {}