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