@furongjun1999/dsh-memory 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +572 -465
- package/docs/README.md +1 -0
- package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
- package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
- package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
- package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
- package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
- package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
- package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
- package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
- package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
- package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
- package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
- package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v1.md +156 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v10.md +210 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v11.md +227 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v12.md +203 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v13.md +233 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v14.md +191 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v15.md +213 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v16.md +214 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v17.md +199 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
- package/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_v2.md +156 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v3.md +152 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v4.md +128 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v5.md +114 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v6.md +192 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v7.md +187 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v8.md +207 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v9.md +203 -0
- package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
- package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
- package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
- package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
- 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
- package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
- package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
- package/dsh/cordis-patch-profile-web.example.yml +35 -0
- package/lib/bridge.d.ts +9 -0
- package/lib/bridge.js +35 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +66 -0
- package/lib/index.js +7 -1
- package/lib/init.d.ts +88 -0
- package/lib/init.js +287 -0
- package/lib/lib/roleplay_web.js +530 -443
- package/lib/lib/token_store.d.ts +7 -1
- package/lib/lib/token_store.js +12 -3
- package/md_cg/audit.py +12 -1
- package/md_cg/backfill.py +16 -15
- package/md_cg/backfill_bucket_zh.py +35 -0
- package/md_cg/bench_e2e_judge.py +532 -0
- package/md_cg/bench_e2e_locomo_qa.py +368 -0
- package/md_cg/bench_e2e_qa.py +256 -0
- package/md_cg/branches.py +18 -2
- package/md_cg/ccgc.py +3 -2
- package/md_cg/chain.py +19 -4
- package/md_cg/consolidate.py +7 -6
- package/md_cg/crosscheck.py +4 -3
- package/md_cg/crypto.py +439 -437
- package/md_cg/datapath.py +395 -335
- package/md_cg/evidence.py +8 -3
- package/md_cg/export.py +3 -1
- package/md_cg/forgetting.py +2 -2
- package/md_cg/fsutil.py +48 -0
- package/md_cg/hotcache.py +255 -238
- package/md_cg/interop.py +161 -22
- package/md_cg/judgment_manifest.py +177 -0
- package/md_cg/links.py +655 -622
- package/md_cg/mcp_server.py +282 -31
- package/md_cg/mdcg.py +702 -126
- package/md_cg/mdcos.py +454 -68
- package/md_cg/mreview/govern.py +5 -4
- package/md_cg/postings.py +4 -2
- package/md_cg/readcache.py +76 -18
- package/md_cg/reconcile.py +228 -0
- package/md_cg/review_cli.py +215 -0
- package/md_cg/routing.py +28 -0
- package/md_cg/run_tests.py +211 -0
- package/md_cg/scrub.py +862 -852
- package/md_cg/security.py +385 -275
- package/md_cg/selfreport.py +3 -2
- package/md_cg/signer.py +565 -562
- package/md_cg/sources.py +3 -2
- package/md_cg/stg.py +6 -0
- package/md_cg/sustain.py +1168 -1138
- package/md_cg/test_access_hints.py +147 -0
- package/md_cg/test_branch_discard_tombstone.py +136 -0
- package/md_cg/test_branches.py +259 -249
- package/md_cg/test_chain_read_isolate.py +168 -0
- package/md_cg/test_datapath_device_name.py +203 -0
- package/md_cg/test_emit_negtail_cache.py +156 -0
- package/md_cg/test_en_pipeline.py +186 -166
- package/md_cg/test_govern_directread.py +421 -0
- package/md_cg/test_i32_hotcache_env_key.py +218 -0
- package/md_cg/test_identity_attribution.py +228 -147
- package/md_cg/test_index_crossprocess_reload.py +301 -0
- package/md_cg/test_index_durability.py +238 -224
- package/md_cg/test_interop.py +4 -2
- package/md_cg/test_interop_judgment.py +228 -0
- package/md_cg/test_issue39_utf8_stdio.py +273 -0
- package/md_cg/test_links_concurrent_write.py +188 -0
- package/md_cg/test_merge_upsert.py +168 -0
- package/md_cg/test_n123_derive_expiry_chain.py +205 -0
- package/md_cg/test_n130_verify_falsified_protect.py +185 -0
- package/md_cg/test_n131_merge_gate.py +205 -0
- package/md_cg/test_none_id_write_guard.py +165 -0
- package/md_cg/test_p1x_ref_root.py +160 -0
- package/md_cg/test_p27_docindex.py +774 -765
- package/md_cg/test_p2_mcp.py +3 -0
- package/md_cg/test_p32_backfill.py +304 -298
- package/md_cg/test_p39_verify_flow.py +90 -50
- package/md_cg/test_p47_session_view.py +60 -25
- package/md_cg/test_propose_tail_index.py +157 -0
- package/md_cg/test_read_scope_b27.py +277 -0
- package/md_cg/test_readcache_default_on.py +168 -0
- package/md_cg/test_readcache_precise_inval.py +270 -0
- package/md_cg/test_readcache_prodpath.py +55 -7
- package/md_cg/test_reconcile_v0.py +294 -0
- package/md_cg/test_retr_s1.py +6 -2
- package/md_cg/test_retr_s1b.py +67 -0
- package/md_cg/test_retr_s7.py +8 -0
- package/md_cg/test_retr_s9_entity_ctx.py +181 -175
- package/md_cg/test_retr_score_once.py +208 -0
- package/md_cg/test_review_cli_attribution.py +177 -0
- package/md_cg/test_review_cli_visibility.py +235 -0
- package/md_cg/test_review_onepass.py +170 -0
- package/md_cg/test_rrf_graph_seed_cache.py +154 -0
- package/md_cg/test_security_audit.py +155 -0
- package/md_cg/test_security_audit_b26.py +161 -0
- package/md_cg/test_security_audit_v21.py +250 -0
- package/md_cg/test_semantic_canonical.py +255 -241
- package/md_cg/test_session_isolation.py +168 -0
- package/md_cg/test_snapshot_autoclose.py +187 -0
- package/md_cg/test_tail_watermark_race.py +208 -0
- package/md_cg/test_tenant_env_override_warn.py +139 -0
- package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
- package/md_cg/test_v14_fixes.py +415 -397
- package/md_cg/test_verify_dirty_reconcile.py +157 -0
- package/md_cg/theory.py +276 -273
- package/md_cg/tokens.py +734 -677
- package/md_cg/units.py +3 -2
- package/md_cg/vision_evidence.py +4 -3
- package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
- package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
- package/md_cg/writepipe.py +554 -550
- package/package.json +11 -2
- package/src/bridge.ts +434 -401
- package/src/cli.ts +65 -0
- package/src/index.ts +526 -518
- package/src/init.ts +347 -0
- package/src/lib/roleplay_web.ts +1019 -932
- package/src/lib/token_store.ts +202 -192
package/md_cg/sustain.py
CHANGED
|
@@ -1,1139 +1,1169 @@
|
|
|
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 .
|
|
61
|
-
from .
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# --------------------------------------------------------------------------
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return None
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if age <= interval *
|
|
151
|
-
return "
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# --------------------------------------------------------------------------
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return False
|
|
191
|
-
if
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
k32.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if
|
|
270
|
-
out
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
out
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
# --------------------------------------------------------------------------
|
|
330
|
-
|
|
331
|
-
#
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return 0
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
# --------------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if mb
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
`
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
"
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
"
|
|
461
|
-
"
|
|
462
|
-
"
|
|
463
|
-
|
|
464
|
-
"
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
issues
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
"
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
"
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
#
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
"
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
#
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
act("
|
|
659
|
-
if "
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
#
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
""
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
#
|
|
764
|
-
def
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
self.
|
|
856
|
-
self.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
self.
|
|
866
|
-
self.
|
|
867
|
-
self.
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
self.
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
self.
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
"
|
|
999
|
-
"
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
"
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
#
|
|
1088
|
-
def
|
|
1089
|
-
"""
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
|
1139
1169
|
return {}
|