@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/sleep.py
ADDED
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · 睡眠周期的 git 机制(P0-1:根级锁 + 影子工作树 + 独立 git 目录)
|
|
3
|
+
|
|
4
|
+
设计正身:`docs/plans/睡眠与自迭代_功能优化设计_v0.4.md` §三(周期引擎九步)/ §四
|
|
5
|
+
(§4.1 / §4.2 / §4.4 / §4.5)/ §4.7(env 表)。**P0-1** 落机制与机械判据(物化 /
|
|
6
|
+
对账+提交 / 合并 / 只 revert),**P1** 在其上接 §4.4 的四阶段协议与 §3.1 的九步
|
|
7
|
+
周期引擎(`run_cycle` / `iterate` / `reconcile_gated` / `merge_cycle`),并由
|
|
8
|
+
`md_cg/sustain.py` 的第六档 tick `_tick_sleep` 驱动。
|
|
9
|
+
|
|
10
|
+
§4.7 的七个 env 键在 `SLEEP_ENV_DEFAULTS` / `SLEEP_ENV_KEYS` 里**只有一份**定义;
|
|
11
|
+
读取点一律经 `sleep_env()` 及其具名包装——不得再写第二处缺省字面量。
|
|
12
|
+
|
|
13
|
+
三条硬约束(§4.1 / §4.2):
|
|
14
|
+
|
|
15
|
+
① **版本库只覆盖真源面**:那 8 个 LAYERS 目录(`md_cg.mdcg.LAYERS`,**单点导入
|
|
16
|
+
不抄第二份**)下的 `.md`;`_keys.json` / `_access.log` / `_index.json` /
|
|
17
|
+
`*.tmp` / `*.lock` 等派生、运行态与密钥面一律不进版本库。
|
|
18
|
+
② **绝不在数据根内建 `.git`**:git 一律以
|
|
19
|
+
`git --git-dir=<state_root>/sleep/lib.git --work-tree=<工作树>` **显式形态**
|
|
20
|
+
调用;`add` 只喂**显式 pathspec 白名单**——**绝不整树 add**(不带 pathspec,
|
|
21
|
+
或以通配/当前目录当 pathspec)。白名单之外结构性不可达:密钥不靠
|
|
22
|
+
`.gitignore` 兜(数据根根本没有 ignore 面),而「哪些路径能入册」是这份
|
|
23
|
+
白名单直接说了算。
|
|
24
|
+
③ 影子是 `git worktree`(分支 `sleep/<时间戳>`),落 `state_root()/sleep/shadow`;
|
|
25
|
+
**三段动作各自持根级锁**(复用 `md_cg.fsutil.FileLock`;锁文件落
|
|
26
|
+
`state_root()` 侧而**非数据根**)。回滚**只给 `revert`**——不提供抹历史的
|
|
27
|
+
强推档(§〇.3-13:历史不丢,回滚自身也进历史)。
|
|
28
|
+
|
|
29
|
+
与 `hive/wm.py` 的关系:**形态照抄**(分支命名、`--no-ff`、冲突诚实报错不自动
|
|
30
|
+
解决、git 操作面串行),**数据面不同**(wm 管蜂巢任务产物,本模块管记忆真源面)。
|
|
31
|
+
|
|
32
|
+
四阶段(§4.4)里本模块的落点:
|
|
33
|
+
|
|
34
|
+
① 物化 `materialize()` —— 持根级锁;主库真源面逐字节不变
|
|
35
|
+
② 迭代 (P1;在影子上跑,本模块不管)
|
|
36
|
+
③ 对账+提交 `reconcile_and_commit()` —— 持根级锁;Δ 为空即零提交零写入
|
|
37
|
+
④ 合并 `merge()` —— 持根级锁;写主库的正是「应写的那部分」
|
|
38
|
+
|
|
39
|
+
脚本式用法(只读判读):`python -X utf8 -m md_cg.sleep --status`
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
import sys
|
|
50
|
+
import time
|
|
51
|
+
|
|
52
|
+
from . import nodefile
|
|
53
|
+
from .datapath import mdcg_root, state_root
|
|
54
|
+
from .fsutil import FileLock, append_jsonl
|
|
55
|
+
from .mdcg import LAYERS
|
|
56
|
+
|
|
57
|
+
#: 睡眠面在状态根下的目录名(**数据根之外**:版本库与影子都不进记忆面)。
|
|
58
|
+
SLEEP_DIRNAME = "sleep"
|
|
59
|
+
GITDIR_DIRNAME = "lib.git"
|
|
60
|
+
SHADOW_DIRNAME = "shadow"
|
|
61
|
+
#: 根级锁文件名(`FileLock` 会再拼 `.lock`)——即 `<state_root>/sleep/sleep.lock`。
|
|
62
|
+
LOCK_NAME = "sleep"
|
|
63
|
+
#: 基线分支与影子分支前缀(§4.1:分支名 `sleep/<时间戳>`)。
|
|
64
|
+
BASE_BRANCH = "main"
|
|
65
|
+
BRANCH_PREFIX = "sleep/"
|
|
66
|
+
GIT_USER_NAME = "lingshu-sleep"
|
|
67
|
+
GIT_USER_EMAIL = "sleep@lingshu.local"
|
|
68
|
+
#: 历史里**零命中**才合规的禁入路径(§4.5 机械判据)。
|
|
69
|
+
FORBIDDEN_BASENAMES = ("_keys.json", "_access.log", "_index.json")
|
|
70
|
+
FORBIDDEN_SUFFIXES = (".tmp", ".lock")
|
|
71
|
+
DEFAULT_LOCK_TIMEOUT = 30.0
|
|
72
|
+
#: 睡眠轮次的**台账**(append-only JSONL)——落 `sleep_root()` 侧(状态面,
|
|
73
|
+
#: **不是数据根**):台账是运行态,不该成为真源面的一部分、也不该进版本库。
|
|
74
|
+
SLEEP_LEDGER = "_sleep.jsonl"
|
|
75
|
+
|
|
76
|
+
# ---- §4.7 env 表的**单一真源**(P1,2026-10-01)---------------------------
|
|
77
|
+
# 为什么要有这张表:§4.7 的七个键此前**全仓零读取点**(P1 探针取证),
|
|
78
|
+
# 若让 `_start_sustain`、op 路径与 `SustainLoop.__init__` 各写一份「缺省字面
|
|
79
|
+
# 量」,就是 P0-2 刚收掉的那种漂移(同一 `(root,name)` 走两条入口得到相反
|
|
80
|
+
# 语义)。纪律:**改缺省只改这里**;所有读取点只经 `sleep_env()` 及其
|
|
81
|
+
# 具名包装(`sleep_enabled` / `sleep_interval` / …),不得再写第二处字面量
|
|
82
|
+
# (守卫 test_sleep_p1.py 把这条钉死:全仓 `MDCG_SLEEP*` 带引号字面量只许
|
|
83
|
+
# 出现在本表的 `SLEEP_ENV_KEYS` 里)。
|
|
84
|
+
SLEEP_ENV_DEFAULTS = {
|
|
85
|
+
"sleep": "1", # 总开关("1" = 开)
|
|
86
|
+
"interval": "3600", # 周期(秒)——一小时一次
|
|
87
|
+
"window": "23:00-07:00", # 睡眠时段(本地时间;留空 = 全时段;支持跨午夜)
|
|
88
|
+
"merge": "auto", # 合并策略 auto / ask / never
|
|
89
|
+
"gitdir": "", # git 目录(空 → state_root()/sleep/lib.git)
|
|
90
|
+
"shadow": "", # 影子工作树(空 → state_root()/sleep/shadow)
|
|
91
|
+
"scrub_apply": "0", # 第④步去污染**实改**闸(缺省关:只盘点不落盘)
|
|
92
|
+
}
|
|
93
|
+
#: 各键的 env 名(**唯一的字面量落点**)。
|
|
94
|
+
SLEEP_ENV_KEYS = {
|
|
95
|
+
"sleep": "MDCG_SLEEP",
|
|
96
|
+
"interval": "MDCG_SLEEP_INTERVAL",
|
|
97
|
+
"window": "MDCG_SLEEP_WINDOW",
|
|
98
|
+
"merge": "MDCG_SLEEP_MERGE",
|
|
99
|
+
"gitdir": "MDCG_SLEEP_GITDIR",
|
|
100
|
+
"shadow": "MDCG_SLEEP_SHADOW",
|
|
101
|
+
"scrub_apply": "MDCG_SLEEP_SCRUB_APPLY",
|
|
102
|
+
}
|
|
103
|
+
#: 关断字面量——与 `sustain.AUTO_OFF_VALUES` 同一口径(0 / false / False)。
|
|
104
|
+
SLEEP_OFF_VALUES = ("0", "false", "False")
|
|
105
|
+
#: `MDCG_SLEEP_MERGE` 的合法取值;越界值回落 "auto"(不猜测、不静默降级成 never)。
|
|
106
|
+
SLEEP_MERGE_MODES = ("auto", "ask", "never")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SleepError(Exception):
|
|
110
|
+
"""git 操作失败(带 stderr 摘要)。"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# 生效条件:name 为 SLEEP_ENV_DEFAULTS 的键时,按 SLEEP_ENV_KEYS[name] 从 environ(缺省 os.environ)取名取值,缺键(None)时回落真源缺省;返回**原始字符串**(不做 bool/float 解释——解释归各具名包装)。name 非表内键时抛 KeyError(不做静默回落:拼错键名即编程错误)。
|
|
114
|
+
def sleep_env(name: str, environ=None) -> str:
|
|
115
|
+
"""§4.7 env 表的唯一读取出口(原始字面量)。"""
|
|
116
|
+
env = os.environ if environ is None else environ
|
|
117
|
+
v = env.get(SLEEP_ENV_KEYS[name])
|
|
118
|
+
return SLEEP_ENV_DEFAULTS[name] if v is None else str(v)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# 生效条件:sleep_env("sleep") 取值不属于 SLEEP_OFF_VALUES 时为真("1"/空串/未设 → 开;"0"/"false"/"False" → 关)。
|
|
122
|
+
def sleep_enabled(environ=None) -> bool:
|
|
123
|
+
"""总开关 `MDCG_SLEEP`(缺省开)。"""
|
|
124
|
+
return sleep_env("sleep", environ) not in SLEEP_OFF_VALUES
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# 生效条件:sleep_env("interval") 可 float() 且 > 0 时返回该 float,否则回落真源缺省 3600.0(非法值不抛、不猜测——回落缺省并在台账里如实标注由调用方负责)。
|
|
128
|
+
def sleep_interval(environ=None) -> float:
|
|
129
|
+
"""周期秒数 `MDCG_SLEEP_INTERVAL`(缺省 3600)。非法/非正值 → 缺省。"""
|
|
130
|
+
try:
|
|
131
|
+
v = float(sleep_env("interval", environ))
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
v = 0.0
|
|
134
|
+
return v if v > 0 else float(SLEEP_ENV_DEFAULTS["interval"])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# 生效条件:sleep_env("window") 的原文(留空 = 全时段);形态校验归 parse_window/in_window。
|
|
138
|
+
def sleep_window(environ=None) -> str:
|
|
139
|
+
"""睡眠时段 `MDCG_SLEEP_WINDOW`(缺省 23:00-07:00)。"""
|
|
140
|
+
return sleep_env("window", environ)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# 生效条件:sleep_env("merge") 去空白转小写后属于 SLEEP_MERGE_MODES 时原样返回,否则回落 "auto"。
|
|
144
|
+
def sleep_merge_mode(environ=None) -> str:
|
|
145
|
+
"""合并策略 `MDCG_SLEEP_MERGE`(缺省 auto)。越界值回落 auto。"""
|
|
146
|
+
m = sleep_env("merge", environ).strip().lower()
|
|
147
|
+
return m if m in SLEEP_MERGE_MODES else "auto"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# 生效条件:sleep_env("scrub_apply") 取值不属于 SLEEP_OFF_VALUES 时为真(缺省 "0" → 假)。
|
|
151
|
+
def sleep_scrub_apply(environ=None) -> bool:
|
|
152
|
+
"""第④步去污染**实改**闸 `MDCG_SLEEP_SCRUB_APPLY`(缺省关)。"""
|
|
153
|
+
return sleep_env("scrub_apply", environ) not in SLEEP_OFF_VALUES
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# 生效条件:无入参;恒返回 state_root() 下 "sleep" 的拼接路径(**不触盘**)。随 ENV_STATE_ROOT / DSH_HOME 解析结果变化——故不得缓存为模块常量。
|
|
157
|
+
def sleep_root() -> str:
|
|
158
|
+
"""睡眠面根目录(版本库 / 影子 / 锁文件的父目录,**在数据根之外**)。"""
|
|
159
|
+
return os.path.join(state_root(), SLEEP_DIRNAME)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# 生效条件:explicit 非空时经 abspath 归一返回;否则取 env MDCG_SLEEP_GITDIR(非空)经 abspath 返回;两者皆空时返回 sleep_root() 下 "lib.git" 的拼接路径。不触盘、不建目录。
|
|
163
|
+
def git_dir(explicit: str = None) -> str:
|
|
164
|
+
"""独立 git 目录(§4.7 `MDCG_SLEEP_GITDIR`;缺省 `state_root()/sleep/lib.git`)。"""
|
|
165
|
+
v = explicit or sleep_env("gitdir") # 键名/缺省的真源 = SLEEP_ENV_KEYS 表
|
|
166
|
+
return os.path.abspath(v) if v else os.path.join(sleep_root(), GITDIR_DIRNAME)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# 生效条件:explicit 非空时经 abspath 归一返回;否则取 env MDCG_SLEEP_SHADOW(非空)经 abspath 返回;两者皆空时返回 sleep_root() 下 "shadow" 的拼接路径。不触盘。
|
|
170
|
+
def shadow_dir(explicit: str = None) -> str:
|
|
171
|
+
"""影子工作树落点(§4.7 `MDCG_SLEEP_SHADOW`;缺省 `state_root()/sleep/shadow`)。"""
|
|
172
|
+
v = explicit or sleep_env("shadow") # 键名/缺省的真源 = SLEEP_ENV_KEYS 表
|
|
173
|
+
return os.path.abspath(v) if v else os.path.join(sleep_root(), SHADOW_DIRNAME)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# 生效条件:无入参;恒返回 sleep_root() 下 "sleep" 的拼接路径(`FileLock` 会再拼 ".lock",即 <state_root>/sleep/sleep.lock)。**绝不在数据根内**——锁件进不了真源面,也进不了版本库白名单。
|
|
177
|
+
def lock_path() -> str:
|
|
178
|
+
"""根级锁文件路径(不含 `.lock` 后缀;`FileLock` 自行拼)。
|
|
179
|
+
|
|
180
|
+
落 `state_root()` 侧是**硬要求**(§八 P0-1):锁文件若落数据根,会与
|
|
181
|
+
`mkstemp` 临时件一起成为「数据根里多出来的件」,白名单拦得住它进版本库,
|
|
182
|
+
但拦不住它进「真源面逐字节对比」以外的观测面——而它本就属于状态面。
|
|
183
|
+
"""
|
|
184
|
+
return os.path.join(sleep_root(), LOCK_NAME)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# 生效条件:shadow 为已挂上的链式工作树(其下 .git 为文件)时,读该文件首行 "gitdir: <路径>" 并返回 abspath 归一后的专属 git 目录;文件缺失/不可读/首行不含 "gitdir:" 时返回空串。
|
|
188
|
+
def worktree_git_dir(shadow: str) -> str:
|
|
189
|
+
"""链式工作树(`git worktree add` 的产物)自己的 git 目录。
|
|
190
|
+
|
|
191
|
+
**为什么必须有这一层**:`git --git-dir=<主 git 目录> --work-tree=<链式工作树>`
|
|
192
|
+
的 HEAD 与 index 都取**主**那一份——在影子上提交会把提交落到 `main`,影子分支
|
|
193
|
+
永远停在基线(本机实测:`rev-parse --abbrev-ref HEAD` 在影子侧也返回 `main`,
|
|
194
|
+
对账提交后 `sleep/<ts>` 仍只有基线提交)。链式工作树的 HEAD/index 在它自己的
|
|
195
|
+
gitdir(`<lib.git>/worktrees/<名>`)里,`--git-dir` 必须指过去。
|
|
196
|
+
`--git-dir` + `--work-tree` 的**显式形态不变**,只是 `--git-dir` 的取值随工作树
|
|
197
|
+
而变(§八 P0-1 要求的是「不用 cwd 猜、两个开关都显式」,本函数正是照此办的)。
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
with open(os.path.join(shadow, ".git"), encoding="utf-8") as f:
|
|
201
|
+
first = f.readline().strip()
|
|
202
|
+
except OSError:
|
|
203
|
+
return ""
|
|
204
|
+
if "gitdir:" not in first:
|
|
205
|
+
return ""
|
|
206
|
+
return os.path.abspath(first.split("gitdir:", 1)[1].strip())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# 生效条件:root 为目录路径时返回 8 个 LAYERS 目录中**盘上存在且其下至少有一个文件**者的目录名列表(顺序同 LAYERS);root 下无任何命中时返回空列表。只 walk 目录、不读文件内容,命中即 break。
|
|
210
|
+
def pathspecs(root: str) -> list:
|
|
211
|
+
"""`add` 的**显式 pathspec 白名单**:`md_cg.mdcg.LAYERS` 中盘上非空者。
|
|
212
|
+
|
|
213
|
+
与引擎**同源**(单点导入 `LAYERS`,不硬编码第二份——两处各写一份必然漂移)。
|
|
214
|
+
空目录必须滤掉:`git add -- <空目录>` 会让**整条命令**以
|
|
215
|
+
`fatal: pathspec ... did not match any files` 失败(本机实测),连同一批
|
|
216
|
+
本该入册的目录一起拒绝;而空目录本就没有可入册的 `.md`。
|
|
217
|
+
"""
|
|
218
|
+
out = []
|
|
219
|
+
for layer in LAYERS:
|
|
220
|
+
base = os.path.join(root, layer)
|
|
221
|
+
for _dp, _dn, files in os.walk(base):
|
|
222
|
+
if files:
|
|
223
|
+
out.append(layer)
|
|
224
|
+
break
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# 生效条件:root 为目录时返回 {相对 root 的正斜杠路径: sha256 十六进制},覆盖 8 个 LAYERS 目录下**全部** .md(按路径排序遍历);单文件读失败记 "!unreadable" 而不抛;root 下无 .md 时返回空 dict。
|
|
229
|
+
def source_face_hashes(root: str) -> dict:
|
|
230
|
+
"""真源面内容哈希集合——「主库真源面逐字节不变」的判据面(§八 P0-1)。
|
|
231
|
+
|
|
232
|
+
与版本库白名单**同口径**:只含 LAYERS 各目录下的 `.md`。派生面
|
|
233
|
+
(`_index.json` / `_index_log/`)、运行态(`_access.log`)、密钥面
|
|
234
|
+
(`_keys.json`)、临时件与锁件都不在内——它们逐字节变不变不是判据。
|
|
235
|
+
"""
|
|
236
|
+
out = {}
|
|
237
|
+
for layer in LAYERS:
|
|
238
|
+
base = os.path.join(root, layer)
|
|
239
|
+
for dirpath, _dn, files in os.walk(base):
|
|
240
|
+
for fn in sorted(files):
|
|
241
|
+
if not fn.endswith(".md"):
|
|
242
|
+
continue
|
|
243
|
+
p = os.path.join(dirpath, fn)
|
|
244
|
+
rel = os.path.relpath(p, root).replace("\\", "/")
|
|
245
|
+
try:
|
|
246
|
+
with open(p, "rb") as f:
|
|
247
|
+
out[rel] = hashlib.sha256(f.read()).hexdigest()
|
|
248
|
+
except OSError:
|
|
249
|
+
out[rel] = "!unreadable"
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# 生效条件:before/after 为 source_face_hashes 形态的 {路径: 哈希} 时,返回 {"added": 新增路径排序表, "removed": 消失路径排序表, "changed": 双侧皆有但哈希不同者排序表}(三者皆可为空)。
|
|
254
|
+
def face_delta(before: dict, after: dict) -> dict:
|
|
255
|
+
"""真源面哈希集合的前后差(三键列表;全空 = 逐字节不变)。"""
|
|
256
|
+
return {"added": sorted(set(after) - set(before)),
|
|
257
|
+
"removed": sorted(set(before) - set(after)),
|
|
258
|
+
"changed": sorted(k for k in set(before) & set(after)
|
|
259
|
+
if before[k] != after[k])}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# 生效条件:git_dir_path 与 work_tree 给定时以 ["git", "--git-dir=<git_dir_path>", "--work-tree=<work_tree>", *args] 调 subprocess.run(capture_output + text + 显式 utf-8/errors=replace + env 带 PYTHONUTF8=1 + shell=False)并返回 CompletedProcess;returncode 非零不抛(由调用方判)。
|
|
263
|
+
def _git(git_dir_path: str, work_tree: str, *args: str) -> subprocess.CompletedProcess:
|
|
264
|
+
"""git 调用的**唯一出口**:`--git-dir` 与 `--work-tree` 一律显式给。
|
|
265
|
+
|
|
266
|
+
`--work-tree` 不是装饰:物化与合并传数据根,对账+提交传影子——同一个版本库
|
|
267
|
+
两个工作树,靠这两个显式开关切换,**不依赖进程 cwd**(`hive/wm.py` 用 `-C`
|
|
268
|
+
是因为它的工作树就是版本库本身;这里两者分居,故用分裂形态)。
|
|
269
|
+
"""
|
|
270
|
+
argv = ["git", "--git-dir=" + git_dir_path, "--work-tree=" + work_tree]
|
|
271
|
+
argv.extend(args)
|
|
272
|
+
env = dict(os.environ, PYTHONUTF8="1")
|
|
273
|
+
return subprocess.run(argv, capture_output=True, text=True,
|
|
274
|
+
encoding="utf-8", errors="replace",
|
|
275
|
+
env=env, shell=False)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# 生效条件:同 _git 的调用形态;returncode != 0 时抛 SleepError(消息取 stderr 为真值、为空则取 stdout,strip 后截 300 字符),为 0 时返回 stdout。
|
|
279
|
+
def _git_ok(git_dir_path: str, work_tree: str, *args: str) -> str:
|
|
280
|
+
r = _git(git_dir_path, work_tree, *args)
|
|
281
|
+
if r.returncode != 0:
|
|
282
|
+
raise SleepError("git %s 失败: %s"
|
|
283
|
+
% (" ".join(args[:2]), (r.stderr or r.stdout).strip()[:300]))
|
|
284
|
+
return r.stdout
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# 生效条件:git_dir_path 为(可建或已存在的)目录;先 makedirs(dirname(git_dir_path), exist_ok=True),若该目录此前不存在则调 git init -b main,随后逐个设 user.name/user.email/core.quotepath(幂等,失败静默);返回 {"created": bool, "git_dir": str}。
|
|
288
|
+
def ensure_repo(root: str, git_dir_path: str) -> dict:
|
|
289
|
+
"""建 / 复用独立 git 目录(幂等)。**绝不在数据根内建 `.git`。**
|
|
290
|
+
|
|
291
|
+
首次 `git --git-dir=<G> --work-tree=<root> init -b main`——非裸仓形态:
|
|
292
|
+
「工作树」由每次调用的 `--work-tree` 显式指定,故数据根里既没有 `.git`
|
|
293
|
+
目录、也没有 `.git` 文件(`--separate-git-dir` 会在数据根留一个 `.git`
|
|
294
|
+
文件,同样不合格)。
|
|
295
|
+
|
|
296
|
+
身份与编码**写进本仓 config**(不依赖全局 git 配置):`core.quotepath=false`
|
|
297
|
+
让中文文件名在 `log --name-only` 输出里可读,否则非 ASCII 路径会被
|
|
298
|
+
C 风格转义,机械判据的 grep 面随之失真。
|
|
299
|
+
|
|
300
|
+
`core.autocrlf=false` + `core.eol=lf` 同理写进本仓 config——**这是
|
|
301
|
+
「真源面逐字节」判据的前提**:本机系统级配置是 `core.autocrlf=true`,
|
|
302
|
+
任何检出都会把工作树里的 LF 折成 CRLF,于是「工作树字节」与「索引字节」
|
|
303
|
+
永久不一致:Δ 恒非空(合并后仍报差异)、`git revert` / `git merge` 被
|
|
304
|
+
「本地改动会被覆盖」挡住(本机实测:只加前两键时 revert 必失败)。
|
|
305
|
+
数据面的真源是**字节**,故本仓显式关闭该折叠;只改本仓 config
|
|
306
|
+
(不动使用者的全局/系统配置)。
|
|
307
|
+
"""
|
|
308
|
+
os.makedirs(os.path.dirname(os.path.abspath(git_dir_path)), exist_ok=True)
|
|
309
|
+
created = not os.path.isdir(git_dir_path)
|
|
310
|
+
if created:
|
|
311
|
+
_git_ok(git_dir_path, root, "init", "-b", BASE_BRANCH)
|
|
312
|
+
for k, v in (("user.name", GIT_USER_NAME), ("user.email", GIT_USER_EMAIL),
|
|
313
|
+
("core.quotepath", "false"),
|
|
314
|
+
("core.autocrlf", "false"), ("core.eol", "lf")):
|
|
315
|
+
_git(git_dir_path, root, "config", k, v)
|
|
316
|
+
return {"created": created, "git_dir": git_dir_path}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# 生效条件:git_dir_path 为 git 仓时返回 git rev-parse --verify --quiet HEAD 的 returncode == 0(即该仓至少有一个提交);非仓或零提交返回 False。
|
|
320
|
+
def _has_head(git_dir_path: str, work_tree: str) -> bool:
|
|
321
|
+
return _git(git_dir_path, work_tree, "rev-parse", "--verify", "--quiet",
|
|
322
|
+
"HEAD").returncode == 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# 生效条件:git_dir_path 为 git 仓且 work_tree 为目录;pathspecs(work_tree) 为空时直接返回 {"committed": False, "reason": "白名单为空…"};否则 git add -- <白名单> 后以 git diff --cached --quiet 判有无暂存改动(rc==0 即无改动),无改动返回 {"committed": False, "reason": "Δ 为空…"}(零提交、零写入);有改动则 commit -m message 并返回 {"committed": True, "commit": <sha>, "pathspecs": [...]}。
|
|
326
|
+
def _stage_and_commit(git_dir_path: str, work_tree: str, message: str) -> dict:
|
|
327
|
+
"""白名单 add + 有改动才 commit(Δ 为空 ⇒ 整轮跳过,§4.4)。"""
|
|
328
|
+
ps = pathspecs(work_tree)
|
|
329
|
+
if not ps:
|
|
330
|
+
return {"committed": False, "reason": "白名单为空(真源面无任何 .md)",
|
|
331
|
+
"pathspecs": ps}
|
|
332
|
+
_git_ok(git_dir_path, work_tree, "add", "--", *ps)
|
|
333
|
+
if _git(git_dir_path, work_tree, "diff", "--cached", "--quiet").returncode == 0:
|
|
334
|
+
return {"committed": False, "reason": "Δ 为空(无待提交改动)",
|
|
335
|
+
"pathspecs": ps}
|
|
336
|
+
_git_ok(git_dir_path, work_tree, "commit", "-m", message)
|
|
337
|
+
return {"committed": True,
|
|
338
|
+
"commit": _git_ok(git_dir_path, work_tree, "rev-parse", "HEAD").strip(),
|
|
339
|
+
"pathspecs": ps}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# 生效条件:无入参;返回形如 "20261001-071530" 的本地时间戳(秒级),用于拼分支名 sleep/<时间戳>。
|
|
343
|
+
def _stamp() -> str:
|
|
344
|
+
return time.strftime("%Y%m%d-%H%M%S")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# 生效条件:phase 与 timeout 给定;返回统一的「根级锁未取得」读数字典(ok=False、busy=True、lock_held=False、lock=lock_path()、timeout 原值、reason 文案)。
|
|
348
|
+
def _busy(phase: str, timeout: float) -> dict:
|
|
349
|
+
return {"ok": False, "busy": True, "lock_held": False, "phase": phase,
|
|
350
|
+
"lock": lock_path(), "timeout": timeout,
|
|
351
|
+
"reason": ("根级锁未取得:另一进程正在睡眠周期内(两进程同时触发时"
|
|
352
|
+
"只有一个能进)")}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# 生效条件:root 为数据根(缺省取 mdcg_root());在 <state_root>/sleep/sleep 的根级锁内,建/复用独立 git 目录并(首次)把真源面入册为 main 基线,再 git worktree add -b sleep/<ts> <shadow> main(分支已存在则复用该分支重试一次);返回含 ok/phase/busy/git_dir/shadow/branch/pathspecs/lock_acquired/source_face_delta/lock 的字典——ok 为真且 source_face_delta 三键全空 = 主库真源面逐字节未变。
|
|
356
|
+
def materialize(root: str = None, *, git_dir_path: str = None,
|
|
357
|
+
shadow: str = None, ts: str = None,
|
|
358
|
+
timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
359
|
+
"""① 物化(§4.4,**持根级锁**)。
|
|
360
|
+
|
|
361
|
+
幂等:影子已存在时先摘(`worktree remove --force`,非注册目录退化为 rmtree)
|
|
362
|
+
再挂。分支已存在(同一秒重入)时退化为复用该分支。
|
|
363
|
+
"""
|
|
364
|
+
root = os.path.abspath(root or mdcg_root())
|
|
365
|
+
G = git_dir_path or git_dir()
|
|
366
|
+
S = shadow or shadow_dir()
|
|
367
|
+
branch = BRANCH_PREFIX + (ts or _stamp())
|
|
368
|
+
before = source_face_hashes(root)
|
|
369
|
+
with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
|
|
370
|
+
if not lk.acquired:
|
|
371
|
+
return _busy("materialize", timeout)
|
|
372
|
+
rep = ensure_repo(root, G)
|
|
373
|
+
init = _stage_and_commit(G, root, "sleep: 基线(真源面入册)") \
|
|
374
|
+
if not _has_head(G, root) else {"committed": False,
|
|
375
|
+
"reason": "已有基线提交"}
|
|
376
|
+
if os.path.isdir(S):
|
|
377
|
+
r = _git(G, root, "worktree", "remove", "--force", S)
|
|
378
|
+
if r.returncode != 0:
|
|
379
|
+
# 非注册工作树(上一次运行被杀、目录残留)→ 直接摘目录
|
|
380
|
+
shutil.rmtree(S, ignore_errors=True)
|
|
381
|
+
r = _git(G, root, "worktree", "add", "-b", branch, S, BASE_BRANCH)
|
|
382
|
+
if r.returncode != 0:
|
|
383
|
+
r2 = _git(G, root, "worktree", "add", S, branch)
|
|
384
|
+
if r2.returncode != 0:
|
|
385
|
+
return {"ok": False, "busy": False, "lock_held": True,
|
|
386
|
+
"phase": "materialize", "lock": lock_path(),
|
|
387
|
+
"error": (r2.stderr or r2.stdout).strip()[:300],
|
|
388
|
+
"hint": "影子分支/目录未能挂上"}
|
|
389
|
+
reused = True
|
|
390
|
+
else:
|
|
391
|
+
reused = False
|
|
392
|
+
after = source_face_hashes(root)
|
|
393
|
+
delta = face_delta(before, after)
|
|
394
|
+
return {"ok": not (delta["added"] or delta["removed"] or delta["changed"]),
|
|
395
|
+
"busy": False, "lock_held": True, "phase": "materialize",
|
|
396
|
+
"git_dir": G, "shadow": S, "branch": branch,
|
|
397
|
+
"branch_reused": reused, "repo_created": rep["created"],
|
|
398
|
+
"baseline": init, "pathspecs": pathspecs(root),
|
|
399
|
+
"lock": lock_path(), "source_face_delta": delta,
|
|
400
|
+
"source_face_count": len(after),
|
|
401
|
+
# §4.4 ① 的**基准水位**:物化这一刻的主库真源面内容哈希集合——
|
|
402
|
+
# 迭代期间主库若被改动,并发闸(§4.4 ③)即以它判定「主库优先」。
|
|
403
|
+
"baseline_face": after}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# 生效条件:root/shadow 给定且影子工作树存在;在根级锁内对**影子**工作树做白名单 add + 有改动才 commit(一次调用 = 一个提交,§4.4 ③),并复核主库真源面在同一窗口内逐字节未变;返回 {"ok", "busy", "lock_held", "phase", "committed", "commit", "branch", "pathspecs", "source_face_delta", "lock"};影子不存在时返回 ok=False 与 error。
|
|
407
|
+
def reconcile_and_commit(root: str = None, *, git_dir_path: str = None,
|
|
408
|
+
shadow: str = None, branch: str = None,
|
|
409
|
+
batch: str = None,
|
|
410
|
+
timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
411
|
+
"""③ 对账 + 提交(§4.4,**持根级锁**)。
|
|
412
|
+
|
|
413
|
+
本轮(P0)只落**机制**:语义差异集 Δ 与四闸(保护 / tombstone / 并发 /
|
|
414
|
+
生命周期)属 P1——在影子上的迭代改动由 P1 负责,本函数只保证
|
|
415
|
+
「白名单 add + 一个提交 + 主库真源面不动」。
|
|
416
|
+
|
|
417
|
+
⚠ 工作树是**影子**不是数据根:这正是「迭代段完全不碰主库」的机制面。
|
|
418
|
+
`--git-dir` 取影子的**专属** gitdir(`worktree_git_dir()`)——取主那份会让
|
|
419
|
+
提交落到 `main` 而影子分支停在基线(见该函数说明的实测)。
|
|
420
|
+
"""
|
|
421
|
+
root = os.path.abspath(root or mdcg_root())
|
|
422
|
+
G = git_dir_path or git_dir()
|
|
423
|
+
S = shadow or shadow_dir()
|
|
424
|
+
before = source_face_hashes(root)
|
|
425
|
+
with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
|
|
426
|
+
if not lk.acquired:
|
|
427
|
+
return _busy("reconcile_and_commit", timeout)
|
|
428
|
+
if not os.path.isdir(S):
|
|
429
|
+
return {"ok": False, "busy": False, "lock_held": True,
|
|
430
|
+
"phase": "reconcile_and_commit", "lock": lock_path(),
|
|
431
|
+
"error": "影子工作树不存在:先 materialize", "shadow": S}
|
|
432
|
+
# 链式工作树用自己的 gitdir(否则 HEAD/index 取主那一份,提交会落到 main)
|
|
433
|
+
GS = worktree_git_dir(S) or G
|
|
434
|
+
br = branch or _git_ok(GS, S, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
435
|
+
msg = "sleep: %s 轮对账提交" % (batch or _stamp())
|
|
436
|
+
res = _stage_and_commit(GS, S, msg)
|
|
437
|
+
after = source_face_hashes(root)
|
|
438
|
+
delta = face_delta(before, after)
|
|
439
|
+
return {"ok": not (delta["added"] or delta["removed"] or delta["changed"]),
|
|
440
|
+
"busy": False, "lock_held": True,
|
|
441
|
+
"phase": "reconcile_and_commit", "git_dir": G,
|
|
442
|
+
"worktree_git_dir": GS, "shadow": S,
|
|
443
|
+
"branch": br, "committed": res["committed"],
|
|
444
|
+
"commit": res.get("commit"),
|
|
445
|
+
"reason": res.get("reason"), "pathspecs": res.get("pathspecs"),
|
|
446
|
+
"lock": lock_path(), "source_face_delta": delta}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# 生效条件:root 与 branch 给定;在根级锁内确认版本库 HEAD 在 BASE_BRANCH 上(否则返回 ok=False 与 error),随后 git merge --no-ff <branch> -m …;returncode != 0 时返回 ok=False 与 conflict(输出含 "CONFLICT")及 error 截 500 字符、hint(冲突不自动解决);成功返回 ok=True 与 merged/commit;返回体附 source_face_delta(本阶段**会**写主库,即「应写的那部分」)。
|
|
450
|
+
def merge(root: str = None, *, git_dir_path: str = None, branch: str,
|
|
451
|
+
shadow: str = None,
|
|
452
|
+
timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
453
|
+
"""④ 合并(§4.4,**持根级锁**):`--no-ff` 把 `sleep/<ts>` 合回 `main`。
|
|
454
|
+
|
|
455
|
+
⚠ 本阶段的工作树是**数据根**——合并会把影子分支引入的真源面变更检出到主库,
|
|
456
|
+
这正是「合并阶段应写的那部分」(§八 P0-1 额外机械判据的例外项)。冲突
|
|
457
|
+
**诚实报错、不自动解决**(照 `hive/wm.py` 既有裁决)。
|
|
458
|
+
|
|
459
|
+
HEAD 不在 `main` 上(例如上一次合并冲突挂起、HEAD 残留在别处)时**拒绝执行**
|
|
460
|
+
——不许在非主线上做合并(与 `wm.py:cmd_merge` 同判据)。
|
|
461
|
+
"""
|
|
462
|
+
root = os.path.abspath(root or mdcg_root())
|
|
463
|
+
G = git_dir_path or git_dir()
|
|
464
|
+
S = shadow or shadow_dir()
|
|
465
|
+
before = source_face_hashes(root)
|
|
466
|
+
with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
|
|
467
|
+
if not lk.acquired:
|
|
468
|
+
return _busy("merge", timeout)
|
|
469
|
+
cur = _git_ok(G, root, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
470
|
+
if cur != BASE_BRANCH:
|
|
471
|
+
return {"ok": False, "busy": False, "lock_held": True, "phase": "merge",
|
|
472
|
+
"lock": lock_path(), "head": cur,
|
|
473
|
+
"error": "当前 HEAD 在 %s,merge 须在 %s 上执行" % (cur, BASE_BRANCH)}
|
|
474
|
+
r = _git(G, root, "merge", "--no-ff", branch, "-m",
|
|
475
|
+
"sleep: merge %s into %s" % (branch, BASE_BRANCH))
|
|
476
|
+
if r.returncode != 0:
|
|
477
|
+
out = (r.stdout + r.stderr).strip()
|
|
478
|
+
return {"ok": False, "busy": False, "lock_held": True, "phase": "merge",
|
|
479
|
+
"lock": lock_path(), "shadow": S,
|
|
480
|
+
"conflict": "CONFLICT" in out, "error": out[:500],
|
|
481
|
+
"source_face_delta": face_delta(before, source_face_hashes(root)),
|
|
482
|
+
"hint": ("冲突不自动解决(照 hive/wm.py 既有裁决):人工/LLM 裁决后 "
|
|
483
|
+
"git add + git commit 收口,或 git merge --abort 放弃本次合并")}
|
|
484
|
+
after = source_face_hashes(root)
|
|
485
|
+
delta = face_delta(before, after)
|
|
486
|
+
return {"ok": True, "busy": False, "lock_held": True, "phase": "merge",
|
|
487
|
+
"lock": lock_path(), "merged": branch,
|
|
488
|
+
"commit": _git_ok(G, root, "rev-parse", "HEAD").strip(),
|
|
489
|
+
"source_face_delta": delta, "main_written": delta["added"] + delta["changed"]}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# 生效条件:commit 为版本库中存在的提交(或分支名);在根级锁内以其父提交数判是否 merge(git rev-list --parents -n1 字段数 > 2 即 merge),是 merge 则附 -m 1,执行 git revert --no-edit <参数> <commit>;returncode != 0 时返回 ok=False 与 conflict/error/hint,成功返回 ok=True、reverted、commit(新提交)与 source_face_delta。**只走 revert,不提供抹历史的强推档**。
|
|
493
|
+
def revert(commit: str, root: str = None, *, git_dir_path: str = None,
|
|
494
|
+
timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
495
|
+
"""回滚(§4.4 / §〇.3-13,**持根级锁**):`git revert` 出一个反向提交。
|
|
496
|
+
|
|
497
|
+
**只提供 revert**:历史不丢、可追(`revert` 的自身提交也进历史)。抹历史的
|
|
498
|
+
强推档(把 HEAD 直接指回旧点、把中间提交整段丢弃的那档)**不实现**——本模块
|
|
499
|
+
里既没有那个子命令,也没有等价的「强推 HEAD 到旧点」路径(守卫把这条钉成
|
|
500
|
+
字面量零命中)。merge commit 自动加 `-m 1`(保留主线侧、撤销分支引入的变更),
|
|
501
|
+
与 `hive/wm.py:cmd_revert` 同口径。
|
|
502
|
+
"""
|
|
503
|
+
root = os.path.abspath(root or mdcg_root())
|
|
504
|
+
G = git_dir_path or git_dir()
|
|
505
|
+
before = source_face_hashes(root)
|
|
506
|
+
with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
|
|
507
|
+
if not lk.acquired:
|
|
508
|
+
return _busy("revert", timeout)
|
|
509
|
+
parents = _git_ok(G, root, "rev-list", "--parents", "-n1", commit).split()
|
|
510
|
+
args = ["revert", "--no-edit"]
|
|
511
|
+
if len(parents) > 2:
|
|
512
|
+
args += ["-m", "1"]
|
|
513
|
+
args.append(commit)
|
|
514
|
+
r = _git(G, root, *args)
|
|
515
|
+
if r.returncode != 0:
|
|
516
|
+
out = (r.stdout + r.stderr).strip()
|
|
517
|
+
return {"ok": False, "busy": False, "lock_held": True, "phase": "revert",
|
|
518
|
+
"lock": lock_path(), "conflict": "CONFLICT" in out,
|
|
519
|
+
"error": out[:500],
|
|
520
|
+
"hint": "冲突不自动解决;处理后可 git revert --continue / --abort"}
|
|
521
|
+
after = source_face_hashes(root)
|
|
522
|
+
return {"ok": True, "busy": False, "lock_held": True, "phase": "revert",
|
|
523
|
+
"lock": lock_path(), "reverted": commit,
|
|
524
|
+
"commit": _git_ok(G, root, "rev-parse", "HEAD").strip(),
|
|
525
|
+
"merge_commit": len(parents) > 2,
|
|
526
|
+
"source_face_delta": face_delta(before, after)}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ---------------- 机械判据(§4.5)----------------
|
|
530
|
+
|
|
531
|
+
# 生效条件:版本库可读时返回 git ls-files 的逐行非空结果(相对工作树的路径,正斜杠);仓不可读抛 SleepError。
|
|
532
|
+
def tracked_paths(root: str = None, *, git_dir_path: str = None) -> list:
|
|
533
|
+
"""版本库当前追踪的路径集合(`git ls-files`)。"""
|
|
534
|
+
root = os.path.abspath(root or mdcg_root())
|
|
535
|
+
G = git_dir_path or git_dir()
|
|
536
|
+
return [ln.strip() for ln in _git_ok(G, root, "ls-files").splitlines()
|
|
537
|
+
if ln.strip()]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# 生效条件:版本库可读时返回 git log --all --name-only 的逐行非空结果(全部引用、全部提交改动过的路径,含重复);仓零提交时返回空列表。
|
|
541
|
+
def history_paths(root: str = None, *, git_dir_path: str = None) -> list:
|
|
542
|
+
"""**全部历史**(所有分支、所有提交)里出现过的路径。"""
|
|
543
|
+
root = os.path.abspath(root or mdcg_root())
|
|
544
|
+
G = git_dir_path or git_dir()
|
|
545
|
+
out = _git_ok(G, root, "log", "--all", "--name-only", "--pretty=format:")
|
|
546
|
+
return [ln.strip() for ln in out.splitlines() if ln.strip()]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# 生效条件:对 history_paths 去重后逐个取 basename;basename 属于 FORBIDDEN_BASENAMES 或以 FORBIDDEN_SUFFIXES 之一结尾者计入命中;返回按路径排序的命中表(零命中 = 合规)。
|
|
550
|
+
def history_forbidden_hits(root: str = None, *,
|
|
551
|
+
git_dir_path: str = None) -> list:
|
|
552
|
+
"""历史里的禁入路径(§4.5:`_keys.json` / `_access.log` / `_index.json` /
|
|
553
|
+
`*.tmp` / `*.lock` **零命中**)。"""
|
|
554
|
+
hits = []
|
|
555
|
+
for p in sorted(set(history_paths(root, git_dir_path=git_dir_path))):
|
|
556
|
+
base = p.rsplit("/", 1)[-1]
|
|
557
|
+
if base in FORBIDDEN_BASENAMES or base.endswith(FORBIDDEN_SUFFIXES):
|
|
558
|
+
hits.append(p)
|
|
559
|
+
return hits
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# 生效条件:root 为目录、版本库可读时,返回 {"ok": bool, "tracked": int, "outside_layers": [...], "missing_md": [...]}——ok 为真当且仅当 tracked 全部落在 LAYERS 目录下(outside_layers 为空)且 LAYERS 下全部 .md 都在 tracked 内(missing_md 为空)。
|
|
563
|
+
def audit_tracking(root: str = None, *, git_dir_path: str = None) -> dict:
|
|
564
|
+
"""§4.5 判据二:追踪集合 **⊆ 8 个 LAYERS 目录** 且 **⊇ 其下全部 `.md`**。"""
|
|
565
|
+
root = os.path.abspath(root or mdcg_root())
|
|
566
|
+
tracked = tracked_paths(root, git_dir_path=git_dir_path)
|
|
567
|
+
outside = [p for p in tracked if p.split("/", 1)[0] not in LAYERS]
|
|
568
|
+
missing = sorted(set(source_face_hashes(root)) - set(tracked))
|
|
569
|
+
return {"ok": not outside and not missing, "root": root, "tracked": len(tracked),
|
|
570
|
+
"outside_layers": outside, "missing_md": missing}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# 生效条件:root 为目录、版本库可读时返回 {"ok": bool, "hits": [...], "scanned": int}——ok 为真当且仅当 history_forbidden_hits 为空。
|
|
574
|
+
def audit_history(root: str = None, *, git_dir_path: str = None) -> dict:
|
|
575
|
+
"""§4.5 判据一:历史里禁入路径零命中。"""
|
|
576
|
+
hits = history_forbidden_hits(root, git_dir_path=git_dir_path)
|
|
577
|
+
return {"ok": not hits, "hits": hits,
|
|
578
|
+
"scanned": len(set(history_paths(root, git_dir_path=git_dir_path)))}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ==========================================================================
|
|
582
|
+
# §3.1 九步周期引擎 · §4.4 四阶段协议(P1)
|
|
583
|
+
# ==========================================================================
|
|
584
|
+
#
|
|
585
|
+
# 分工(§4.4 + §4.2-7):**git 是记录者,不是裁决者**。
|
|
586
|
+
# ① 物化 `materialize()`(P0-1,持根级锁)
|
|
587
|
+
# ② 迭代 `iterate()`(在影子上跑 §3.1 的 ②–⑦ 步)——**完全不碰主库**
|
|
588
|
+
# ③ 对账 `reconcile_gated()`——算语义差异集 Δ,逐项过**语义四闸**,
|
|
589
|
+
# 只把通过者提交到 `sleep/<ts>`(Δ 空 ⇒ 零提交零写入)
|
|
590
|
+
# ④ 合并 `merge_cycle()`——先语义重放 Δ 到主库(正常写路径 + 每条
|
|
591
|
+
# `evolution.record` before/after),再 `git merge --no-ff`;
|
|
592
|
+
# 冲突项**挂起不自动解决**,出清单
|
|
593
|
+
# `run_cycle()` 是这四段的编排(常驻循环的第六档 tick 调它)。
|
|
594
|
+
|
|
595
|
+
#: §3.1 九步(显式命名;台账顺序即此序——不许悄悄跳过、不许改名)。
|
|
596
|
+
STEPS = ("recall_scan", "induce", "promote", "scrub", "weights", "reindex",
|
|
597
|
+
"record", "feedback", "self_check")
|
|
598
|
+
STEP_NAMES = {
|
|
599
|
+
"recall_scan": "感知盘点(只读)",
|
|
600
|
+
"induce": "归纳",
|
|
601
|
+
"promote": "升层(情境→知识/长期)",
|
|
602
|
+
"scrub": "巩固与去污染",
|
|
603
|
+
"weights": "权重刷新与衰减",
|
|
604
|
+
"reindex": "索引重建(六要素/时间/因果)",
|
|
605
|
+
"record": "记录",
|
|
606
|
+
"feedback": "反馈",
|
|
607
|
+
"self_check": "方向性自检",
|
|
608
|
+
}
|
|
609
|
+
#: 本轮落成**显式 no-op 占位**的两步(后批接线)——台账里标 `skipped: "未接线"`。
|
|
610
|
+
#: 这是本批**有意不改检索读数**的那两步(设计稿 §3.1 的 5/6 行;接线归 P4 / P2P3)。
|
|
611
|
+
NOT_WIRED = {"weights": "P4(权重刷新与衰减进主分数)",
|
|
612
|
+
"reindex": "P2/P3(六要素索引 / 图检索路)"}
|
|
613
|
+
#: 第⑨步方向性自检的节奏(§3.1:对齐理论「默认每 100 轮」)。
|
|
614
|
+
SELF_CHECK_EVERY = 100
|
|
615
|
+
#: 语义四闸的名字(§4.4 ③,顺序即裁决顺序)。
|
|
616
|
+
GATES = ("protect", "tombstone", "concurrent", "lifecycle")
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# 生效条件:无入参;恒返回 sleep_root() 下 SLEEP_LEDGER 的拼接路径(睡眠轮次台账;不触盘)。
|
|
620
|
+
def ledger_path() -> str:
|
|
621
|
+
return os.path.join(sleep_root(), SLEEP_LEDGER)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# 生效条件:无入参;sleep_root() 下 SLEEP_LEDGER 可读时返回其逐行 json.loads 成功的记录列表(坏行跳过),不可读/不存在返回 [];limit 为真值时只返回最后 int(limit) 条。
|
|
625
|
+
def read_ledger(limit=None) -> list:
|
|
626
|
+
out = []
|
|
627
|
+
try:
|
|
628
|
+
with open(ledger_path(), encoding="utf-8", errors="replace") as f:
|
|
629
|
+
for line in f:
|
|
630
|
+
line = line.strip()
|
|
631
|
+
if not line:
|
|
632
|
+
continue
|
|
633
|
+
try:
|
|
634
|
+
r = json.loads(line)
|
|
635
|
+
except ValueError:
|
|
636
|
+
continue
|
|
637
|
+
if isinstance(r, dict):
|
|
638
|
+
out.append(r)
|
|
639
|
+
except OSError:
|
|
640
|
+
return []
|
|
641
|
+
return out[-int(limit):] if limit else out
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# 生效条件:rec 为 dict 时向 sleep_root()/SLEEP_LEDGER 追加一行 JSON(先 makedirs(sleep_root()));无返回值,写入失败抛 OSError。
|
|
645
|
+
def _append_ledger(rec: dict):
|
|
646
|
+
os.makedirs(sleep_root(), exist_ok=True)
|
|
647
|
+
append_jsonl(ledger_path(), rec)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# 生效条件:batch 为真值时返回台账中是否存在 batch 相同且 merged 为真的记录(幂等判据);batch 为假值返回 False。
|
|
651
|
+
def batch_merged(batch: str) -> bool:
|
|
652
|
+
if not batch:
|
|
653
|
+
return False
|
|
654
|
+
return any(r.get("batch") == batch and r.get("merged") is True
|
|
655
|
+
for r in read_ledger())
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# 生效条件:root 给定且 md_cg.mdcos 可导入时返回该 root 上的 MdCGOS 实例;导入失败抛原异常。
|
|
659
|
+
def _open_cg(root: str):
|
|
660
|
+
from .mdcos import MdCGOS
|
|
661
|
+
return MdCGOS(root)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
# ---------------- 睡眠时段窗口(§4.7:本地时间 / 支持跨午夜)----------------
|
|
665
|
+
|
|
666
|
+
# 生效条件:s 形如 "HH:MM" 且时分在界内时返回当日的分钟数(h*60+m);形态不符或越界抛 ValueError。
|
|
667
|
+
def _hhmm(s) -> int:
|
|
668
|
+
parts = str(s).strip().split(":")
|
|
669
|
+
if len(parts) != 2:
|
|
670
|
+
raise ValueError("时间形态须为 HH:MM:%r" % (s,))
|
|
671
|
+
h, m = int(parts[0]), int(parts[1])
|
|
672
|
+
if not (0 <= h <= 23 and 0 <= m <= 59):
|
|
673
|
+
raise ValueError("时分越界:%r" % (s,))
|
|
674
|
+
return h * 60 + m
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# 生效条件:spec 去空白后为空时返回 None(= 全时段,唯一显式入口);否则要求恰含一个 "-" 且两侧均为合法 HH:MM(时分界内)才返回 (起始分钟, 结束分钟);形态非法(无 "-" / 多段 / 非数字 / 越界)时回落**缺省窗** `SLEEP_ENV_DEFAULTS["window"]` 解析出的分钟对——**不回落全时段**。
|
|
678
|
+
def parse_window(spec):
|
|
679
|
+
"""窗口解析:**留空 → None(全时段,唯一显式入口)**;**非法形态 → 回落缺省窗**。
|
|
680
|
+
|
|
681
|
+
裁定⑰(2026-10-01,前沿小修):此前「留空」与「非法形态」被合并成 None(=
|
|
682
|
+
全时段),即**用户把 `MDCG_SLEEP_WINDOW` 打错字**(实测 `in_window("乱写",
|
|
683
|
+
"12:00") is True`,如 `23:00-99:99`)会让睡眠周期在**白天也迭代**——fail-open。
|
|
684
|
+
现按同表既有惯例分流(`sleep_interval`「非法/非正回落 3600」、
|
|
685
|
+
`sleep_merge_mode`「越界回落 auto」同款):**非法 → 回落缺省窗**,
|
|
686
|
+
宁可退回保守时段,也不把「打错字」当成「允许全天迭代」。
|
|
687
|
+
"""
|
|
688
|
+
s = str(spec or "").strip()
|
|
689
|
+
if not s:
|
|
690
|
+
return None # 留空 = 全时段(唯一显式入口)
|
|
691
|
+
parts = s.split("-")
|
|
692
|
+
if len(parts) == 2:
|
|
693
|
+
try:
|
|
694
|
+
return (_hhmm(parts[0]), _hhmm(parts[1]))
|
|
695
|
+
except (TypeError, ValueError):
|
|
696
|
+
pass
|
|
697
|
+
a, _sep, b = str(SLEEP_ENV_DEFAULTS["window"]).partition("-")
|
|
698
|
+
return (_hhmm(a), _hhmm(b)) # 非法形态 → 缺省窗(单一真源,不写第二处字面量)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# 生效条件:now 为 None 时取 time.localtime();为 str 时按 HH:MM 解析;为 int/float 时按该时间戳 localtime();为对象时取 tm_hour/tm_min(time.struct_time)或 hour/minute(datetime.time);返回当日分钟数。
|
|
702
|
+
def _minute_of(now=None) -> int:
|
|
703
|
+
if now is None:
|
|
704
|
+
lt = time.localtime()
|
|
705
|
+
return lt.tm_hour * 60 + lt.tm_min
|
|
706
|
+
if isinstance(now, str):
|
|
707
|
+
return _hhmm(now)
|
|
708
|
+
if isinstance(now, (int, float)):
|
|
709
|
+
lt = time.localtime(float(now))
|
|
710
|
+
return lt.tm_hour * 60 + lt.tm_min
|
|
711
|
+
hh = getattr(now, "tm_hour", None)
|
|
712
|
+
mm = getattr(now, "tm_min", None)
|
|
713
|
+
if hh is None:
|
|
714
|
+
hh = getattr(now, "hour", None)
|
|
715
|
+
mm = getattr(now, "minute", 0)
|
|
716
|
+
if hh is None:
|
|
717
|
+
raise TypeError("不支持的时刻形态:%r" % (now,))
|
|
718
|
+
return int(hh) * 60 + int(mm or 0)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# 生效条件:spec 解析为 None(留空)时恒返回 True(全时段);起止相同亦返回 True;否则取 _minute_of(now) 后——起 < 止 时判 [起, 止),起 > 止(跨午夜)时判 t>=起 或 t<止。
|
|
722
|
+
def in_window(spec, now=None) -> bool:
|
|
723
|
+
"""窗口判定:**支持跨午夜**(23:00-07:00 在 23:30 / 03:00 在窗内、12:00 在窗外);留空 = 全时段恒真;**非法形态回落缺省窗**(裁定⑰)。"""
|
|
724
|
+
w = parse_window(spec)
|
|
725
|
+
if w is None:
|
|
726
|
+
return True
|
|
727
|
+
start, end = w
|
|
728
|
+
if start == end:
|
|
729
|
+
return True
|
|
730
|
+
t = _minute_of(now)
|
|
731
|
+
if start < end:
|
|
732
|
+
return start <= t < end
|
|
733
|
+
return t >= start or t < end
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ---------------- 真源面 / 语义差异集 ----------------
|
|
737
|
+
|
|
738
|
+
# 生效条件:root 为目录时返回 {node_id: {"rel","abs","layer"}},覆盖 8 个 LAYERS 目录下**全部** .md(node_id = 文件名主干,与 md_cg 既有口径一致);root 下无 .md 返回 {}。
|
|
739
|
+
def node_files(root: str) -> dict:
|
|
740
|
+
out = {}
|
|
741
|
+
for layer in LAYERS:
|
|
742
|
+
base = os.path.join(root, layer)
|
|
743
|
+
for dirpath, _dn, files in os.walk(base):
|
|
744
|
+
for fn in sorted(files):
|
|
745
|
+
if not fn.endswith(".md"):
|
|
746
|
+
continue
|
|
747
|
+
p = os.path.join(dirpath, fn)
|
|
748
|
+
out[fn[:-3]] = {"rel": os.path.relpath(p, root).replace("\\", "/"),
|
|
749
|
+
"abs": p, "layer": layer}
|
|
750
|
+
return out
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# 生效条件:p 可读时返回其 sha256 十六进制;不可读返回 None。
|
|
754
|
+
def _sha256(p: str):
|
|
755
|
+
try:
|
|
756
|
+
with open(p, "rb") as f:
|
|
757
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
758
|
+
except OSError:
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# 生效条件:p 可读且 nodefile.loads 解析成功时返回 (frontmatter dict, 正文 str);不可读或解析抛异常时返回 (None, None)。
|
|
763
|
+
def _fm_of(p: str):
|
|
764
|
+
try:
|
|
765
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
766
|
+
fm, content = nodefile.loads(f.read())
|
|
767
|
+
except (OSError, ValueError):
|
|
768
|
+
return None, None
|
|
769
|
+
return (fm or {}), (content or "")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# 生效条件:fm 为 None 时返回 None;否则返回 evolution.STATE_FIELDS 中存在于 fm 且值非 None 的键值对(「显式为空」的值保留)。
|
|
773
|
+
def _state_view(fm):
|
|
774
|
+
if fm is None:
|
|
775
|
+
return None
|
|
776
|
+
from . import evolution as _evo
|
|
777
|
+
return {k: fm[k] for k in _evo.STATE_FIELDS if k in fm and fm[k] is not None}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# 生效条件:main_root 与 shadow_root 为目录时返回按 node_id 键控的语义差异集——双侧皆有而 sha256 不同记 kind="changed"、仅影子有记 "added"、仅主库有记 "removed";每条带 before/after 认知状态视图、双侧层位、双侧相对路径与双侧 sha256;无差异返回 {}。
|
|
781
|
+
def semantic_delta(main_root: str, shadow_root: str) -> dict:
|
|
782
|
+
"""Δ = {id → (before, after)}(§4.4 ③;按真源面 .md 的**内容哈希**判定)。"""
|
|
783
|
+
A, B = node_files(main_root), node_files(shadow_root)
|
|
784
|
+
out = {}
|
|
785
|
+
for nid in sorted(set(A) | set(B)):
|
|
786
|
+
a, b = A.get(nid), B.get(nid)
|
|
787
|
+
ha = _sha256(a["abs"]) if a else None
|
|
788
|
+
hb = _sha256(b["abs"]) if b else None
|
|
789
|
+
if a and b:
|
|
790
|
+
if ha == hb:
|
|
791
|
+
continue
|
|
792
|
+
kind = "changed"
|
|
793
|
+
else:
|
|
794
|
+
kind = "added" if b else "removed"
|
|
795
|
+
fma = _fm_of(a["abs"])[0] if a else None
|
|
796
|
+
fmb = _fm_of(b["abs"])[0] if b else None
|
|
797
|
+
out[nid] = {"kind": kind,
|
|
798
|
+
"main_rel": (a or {}).get("rel"),
|
|
799
|
+
"shadow_rel": (b or {}).get("rel"),
|
|
800
|
+
"from_layer": (a or {}).get("layer"),
|
|
801
|
+
"to_layer": (b or {}).get("layer"),
|
|
802
|
+
"before": _state_view(fma), "after": _state_view(fmb),
|
|
803
|
+
"before_lifecycle": (fma or {}).get("lifecycle_state"),
|
|
804
|
+
"after_lifecycle": (fmb or {}).get("lifecycle_state"),
|
|
805
|
+
"main_hash": ha, "shadow_hash": hb}
|
|
806
|
+
return out
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# 生效条件:root 下 _deletions.jsonl 可读时返回其中 id 字段的集合(不含 None),不可读返回 set()。
|
|
810
|
+
def _tombstoned_ids(root: str) -> set:
|
|
811
|
+
out = set()
|
|
812
|
+
p = os.path.join(root, "_deletions.jsonl")
|
|
813
|
+
if not os.path.exists(p):
|
|
814
|
+
return out
|
|
815
|
+
try:
|
|
816
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
817
|
+
for line in f:
|
|
818
|
+
line = line.strip()
|
|
819
|
+
if not line:
|
|
820
|
+
continue
|
|
821
|
+
try:
|
|
822
|
+
r = json.loads(line)
|
|
823
|
+
except ValueError:
|
|
824
|
+
continue
|
|
825
|
+
if isinstance(r, dict) and r.get("id"):
|
|
826
|
+
out.add(r["id"])
|
|
827
|
+
except OSError:
|
|
828
|
+
pass
|
|
829
|
+
return out
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
# 生效条件:cg_main 为**主库**实例、delta 为 semantic_delta 形状、baseline_face 为 materialize 记录的基准水位(rel→sha256)时,逐项按 §4.4 ③ 的**语义四闸**裁决:保护闸(protect.is_immutable;且「影子侧删除」恒拒——自动整理不删节点)、tombstone 闸(主库 _deletions.jsonl 已记的 id 不得复活)、并发闸(主库在迭代期间改动/新增过同一 id ⇒ 主库优先,记 conflict)、生命周期闸(lifecycle.can_transition);返回 {"accepted": [id…], "conflicts": [ {id,gate,reason,kind}… ], "rejected": [ {id,gate,reason,kind}… ]}。
|
|
833
|
+
def gate_delta(cg_main, delta: dict, *, baseline_face: dict = None,
|
|
834
|
+
shadow_root: str = None) -> dict:
|
|
835
|
+
"""语义四闸(§4.4 ③)——**git 的自动文本合并绕过这些闸,故准入在这里**。"""
|
|
836
|
+
from . import crypto as _crypto
|
|
837
|
+
from . import lifecycle as _lc
|
|
838
|
+
from . import protect as _pr
|
|
839
|
+
base = baseline_face or {}
|
|
840
|
+
tomb = _tombstoned_ids(cg_main.root)
|
|
841
|
+
accepted, rejected, conflicts = [], [], []
|
|
842
|
+
for nid in sorted(delta):
|
|
843
|
+
d = delta[nid]
|
|
844
|
+
kind = d["kind"]
|
|
845
|
+
# ⓪ 密文项(保护闸的 fail-closed 面):影子侧是密文 ⇒ **不在重放面**。
|
|
846
|
+
# 为什么必须在**这里**拦:重放端 `crypto.is_encrypted` 只在写之前跳
|
|
847
|
+
# 过它,而它仍会随影子分支入册 ⇒ 重放后的主库树与影子树不同 ⇒ 合并
|
|
848
|
+
# 被 `tree_divergence` 永久挂起(一处密文节点即堵死整条合并路)。
|
|
849
|
+
# 在此排除后它不进提交、不参与合并(与 consolidate 的 skipped_locked 同款口径)。
|
|
850
|
+
if shadow_root and d.get("shadow_rel"):
|
|
851
|
+
_fm_enc, _c_enc = _fm_of(os.path.join(shadow_root, d["shadow_rel"]))
|
|
852
|
+
if _fm_enc is not None and _crypto.is_encrypted(_c_enc):
|
|
853
|
+
rejected.append({"id": nid, "gate": "protect", "kind": kind,
|
|
854
|
+
"reason": ("影子侧为密文节点(无密钥不重放、"
|
|
855
|
+
"fail-closed),不入册也不合并")})
|
|
856
|
+
continue
|
|
857
|
+
# ① 保护闸:不可篡改节点不得被改写;**删除永不由自动整理发起**。
|
|
858
|
+
if kind == "removed":
|
|
859
|
+
rejected.append({"id": nid, "gate": "protect", "kind": kind,
|
|
860
|
+
"reason": ("自动整理不删节点(五环承诺:遗忘只由显式 "
|
|
861
|
+
"cg(op=forget) 发起)")})
|
|
862
|
+
continue
|
|
863
|
+
prot, why = _pr.is_immutable(cg_main, nid)
|
|
864
|
+
if prot:
|
|
865
|
+
rejected.append({"id": nid, "gate": "protect", "kind": kind,
|
|
866
|
+
"reason": why})
|
|
867
|
+
continue
|
|
868
|
+
# ② tombstone 闸:主库已遗忘的 id 不得复活。
|
|
869
|
+
if nid in tomb:
|
|
870
|
+
rejected.append({"id": nid, "gate": "tombstone", "kind": kind,
|
|
871
|
+
"reason": "主库 _deletions.jsonl 已有该 id 的删除记录,不得复活"})
|
|
872
|
+
continue
|
|
873
|
+
# ③ 并发闸:主库在迭代期间改动/新增过同一 id ⇒ **主库优先**,本项挂起。
|
|
874
|
+
mrel = d.get("main_rel")
|
|
875
|
+
if mrel:
|
|
876
|
+
cur = base.get(mrel)
|
|
877
|
+
if cur is None or d.get("main_hash") != cur:
|
|
878
|
+
conflicts.append({"id": nid, "gate": "concurrent", "kind": kind,
|
|
879
|
+
"reason": ("主库在迭代期间改动过同一 id(或新增同名 id)"
|
|
880
|
+
"——主库优先,本项挂起不自动解决")})
|
|
881
|
+
continue
|
|
882
|
+
# ④ 生命周期闸:非法迁移不得由睡眠周期写进主库。
|
|
883
|
+
src = d.get("before_lifecycle") or "active"
|
|
884
|
+
dst = d.get("after_lifecycle") or "active"
|
|
885
|
+
if not _lc.can_transition(src, dst):
|
|
886
|
+
rejected.append({"id": nid, "gate": "lifecycle", "kind": kind,
|
|
887
|
+
"reason": "非法生命周期迁移 %s→%s" % (src, dst)})
|
|
888
|
+
continue
|
|
889
|
+
accepted.append(nid)
|
|
890
|
+
return {"accepted": accepted, "conflicts": conflicts, "rejected": rejected}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# 生效条件:在**影子**工作树上只对 rels 列出的相对路径做白名单 add(显式单文件 pathspec,不走整树 add),暂存区无改动时返回 {"committed": False, "reason": "Δ 为空(无待提交改动)"},有改动则 commit -m message 并返回 {"committed": True, "commit": <sha>, "paths": rels};rels 为空即返回零提交。
|
|
894
|
+
def _commit_paths(git_dir_path: str, work_tree: str, rels, message: str) -> dict:
|
|
895
|
+
rels = [r for r in dict.fromkeys(rels or []) if r]
|
|
896
|
+
if not rels:
|
|
897
|
+
return {"committed": False, "reason": "Δ 为空(无待提交改动)", "paths": []}
|
|
898
|
+
_git_ok(git_dir_path, work_tree, "add", "--", *rels)
|
|
899
|
+
if _git(git_dir_path, work_tree, "diff", "--cached", "--quiet").returncode == 0:
|
|
900
|
+
return {"committed": False, "reason": "Δ 为空(无待提交改动)",
|
|
901
|
+
"paths": rels}
|
|
902
|
+
_git_ok(git_dir_path, work_tree, "commit", "-m", message)
|
|
903
|
+
return {"committed": True,
|
|
904
|
+
"commit": _git_ok(git_dir_path, work_tree, "rev-parse", "HEAD").strip(),
|
|
905
|
+
"paths": rels}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# 生效条件:root 与 shadow 给定的相对路径集合按 (影子路径 ∪ 主库原路径) 去重返回(层迁移=「新位置新增 + 旧位置删除」两条都要入册,否则合并时旧文件会留在主库)。
|
|
909
|
+
def _stage_set(delta: dict, accepted) -> list:
|
|
910
|
+
rels = []
|
|
911
|
+
for nid in accepted:
|
|
912
|
+
d = delta[nid]
|
|
913
|
+
rels.append(d.get("shadow_rel"))
|
|
914
|
+
if d.get("main_rel") and d["main_rel"] != d.get("shadow_rel"):
|
|
915
|
+
rels.append(d["main_rel"])
|
|
916
|
+
return [r for r in dict.fromkeys(rels) if r]
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
# 生效条件:root 与 shadow 给定且 shadow 存在时,在**根级锁**内算 Δ 并过四闸,把通过者提交到影子分支(rel 集 = 影子新路径 ∪ 主库旧路径),返回 {"ok","busy","lock_held","phase","delta","accepted","conflicts","rejected","committed","commit","paths","branch","source_face_delta"};取不到锁返回 _busy;影子不存在返回 ok=False 与 error;Δ 为空 ⇒ 零提交、零写入。
|
|
920
|
+
def reconcile_gated(root: str = None, *, git_dir_path: str = None,
|
|
921
|
+
shadow: str = None, branch: str = None, batch: str = None,
|
|
922
|
+
baseline_face: dict = None, cg=None,
|
|
923
|
+
timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
924
|
+
"""③ 对账 + 提交(§4.4,**持根级锁**):Δ 逐项过四闸 → 只把通过者入册。"""
|
|
925
|
+
root = os.path.abspath(root or mdcg_root())
|
|
926
|
+
G = git_dir_path or git_dir()
|
|
927
|
+
S = shadow or shadow_dir()
|
|
928
|
+
before = source_face_hashes(root)
|
|
929
|
+
with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
|
|
930
|
+
if not lk.acquired:
|
|
931
|
+
return _busy("reconcile_gated", timeout)
|
|
932
|
+
if not os.path.isdir(S):
|
|
933
|
+
return {"ok": False, "busy": False, "lock_held": True,
|
|
934
|
+
"phase": "reconcile_gated", "lock": lock_path(),
|
|
935
|
+
"error": "影子工作树不存在:先 materialize", "shadow": S}
|
|
936
|
+
cgm = cg or _open_cg(root)
|
|
937
|
+
delta = semantic_delta(root, S)
|
|
938
|
+
gated = gate_delta(cgm, delta, baseline_face=baseline_face,
|
|
939
|
+
shadow_root=S)
|
|
940
|
+
GS = worktree_git_dir(S) or G
|
|
941
|
+
br = branch or _git_ok(GS, S, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
942
|
+
rels = _stage_set(delta, gated["accepted"])
|
|
943
|
+
res = _commit_paths(GS, S, rels,
|
|
944
|
+
"sleep: %s 轮对账提交" % (batch or _stamp()))
|
|
945
|
+
after = source_face_hashes(root)
|
|
946
|
+
return {"ok": True, "busy": False, "lock_held": True,
|
|
947
|
+
"phase": "reconcile_gated", "git_dir": G, "worktree_git_dir": GS,
|
|
948
|
+
"shadow": S, "branch": br, "delta": delta, **gated,
|
|
949
|
+
"committed": res["committed"], "commit": res.get("commit"),
|
|
950
|
+
"reason": res.get("reason"), "paths": res["paths"],
|
|
951
|
+
"lock": lock_path(),
|
|
952
|
+
"source_face_delta": face_delta(before, after)}
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
# 生效条件:cg_main 为主库实例、shadow_root 为影子目录、delta/accepted 来自 gate_delta 时,把通过项**逐条语义重放**到主库——guard_write 放行后经 `_write_node` 写到影子侧同一相对路径(层迁移时删掉主库旧路径),每条记 `evolution.record`(before/after),末尾 rebuild_index();密文节点 fail-closed 跳过(`crypto.is_encrypted`);返回 {"written": [id…], "skipped": […], "failed": […], "entries": [entry_id…]}。
|
|
956
|
+
def replay_to_main(cg_main, shadow_root: str, delta: dict, accepted,
|
|
957
|
+
*, batch: str = None, actor: str = "sleep") -> dict:
|
|
958
|
+
"""④ 的第一半:语义重放 Δ 到主库(**走正常写路径** + 每条 before/after)。"""
|
|
959
|
+
from . import crypto as _crypto
|
|
960
|
+
from . import evolution as _evo
|
|
961
|
+
from . import protect as _pr
|
|
962
|
+
root = cg_main.root
|
|
963
|
+
written, skipped, failed, entries = [], [], [], []
|
|
964
|
+
for nid in accepted:
|
|
965
|
+
d = delta[nid]
|
|
966
|
+
srel = d.get("shadow_rel")
|
|
967
|
+
if not srel:
|
|
968
|
+
continue
|
|
969
|
+
fm, content = _fm_of(os.path.join(shadow_root, srel))
|
|
970
|
+
if fm is None:
|
|
971
|
+
failed.append({"id": nid, "error": "影子节点不可读"})
|
|
972
|
+
continue
|
|
973
|
+
if _crypto.is_encrypted(content):
|
|
974
|
+
skipped.append({"id": nid, "reason": "密文节点不在睡眠重放面(fail-closed)"})
|
|
975
|
+
continue
|
|
976
|
+
try:
|
|
977
|
+
_pr.guard_write(cg_main, nid, layer=d.get("to_layer"), actor=actor)
|
|
978
|
+
cg_main._write_node(nid, os.path.join(root, srel), fm, content)
|
|
979
|
+
mrel = d.get("main_rel")
|
|
980
|
+
if mrel and mrel != srel:
|
|
981
|
+
old = os.path.join(root, mrel)
|
|
982
|
+
if os.path.exists(old):
|
|
983
|
+
os.remove(old) # 层迁移:旧位置随之清掉(否则主库留双份)
|
|
984
|
+
except Exception as e: # noqa: BLE001 —— 单条失败不带垮整轮
|
|
985
|
+
failed.append({"id": nid, "error": "%s: %s" % (type(e).__name__, e)})
|
|
986
|
+
continue
|
|
987
|
+
try:
|
|
988
|
+
rb = _evo.record(cg_main, node_id=nid,
|
|
989
|
+
pattern="睡眠周期副本迭代 → 合并回主库(语义重放)",
|
|
990
|
+
missing="", action="语义重放 Δ(%s)" % d["kind"],
|
|
991
|
+
evidence="batch=%s gate=ok" % batch,
|
|
992
|
+
source="sleep", kind=_evo.KIND_GENERAL,
|
|
993
|
+
before=d.get("before"), after=d.get("after"),
|
|
994
|
+
extra={"batch": batch})
|
|
995
|
+
entries.append(rb.get("entry_id"))
|
|
996
|
+
except Exception as e: # noqa: BLE001 —— 账本失败不阻断写入
|
|
997
|
+
entries.append({"error": "%s: %s" % (type(e).__name__, e)})
|
|
998
|
+
written.append(nid)
|
|
999
|
+
if written:
|
|
1000
|
+
try:
|
|
1001
|
+
cg_main.rebuild_index()
|
|
1002
|
+
except Exception: # noqa: BLE001 —— 索引可重建
|
|
1003
|
+
pass
|
|
1004
|
+
return {"written": written, "skipped": skipped, "failed": failed,
|
|
1005
|
+
"entries": entries}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# 生效条件:cg_main/root/branch 给定时,在**根级锁**内——先确认主库工作树 HEAD 在 BASE_BRANCH;把重放结果(+ Δ 的日志面)在白名单 pathspec 上入册并提交为**主库侧的语义重放提交**(= 本轮**内容所在**的提交,回退入口 `round_commit`);随后确认影子分支与 HEAD 的**树完全相同**(`git diff --quiet` 判)才 `git merge --no-ff <branch>` 留下合并提交(**拓扑记录**:其树同第一父提交,故对内容零影响),树不同则**挂起不合并**(返回 deferred="tree_divergence" 与 conflict_ids);返回含 ok/phase/replayed/round_commit/replay_commit/merge_commit/conflict/source_face_delta 的字典。
|
|
1009
|
+
def merge_cycle(cg_main, root: str = None, *, git_dir_path: str = None,
|
|
1010
|
+
branch: str, shadow: str = None, delta: dict = None,
|
|
1011
|
+
accepted=None, batch: str = None, replay: bool = True,
|
|
1012
|
+
timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
1013
|
+
"""④ 合并(§4.4,**持根级锁**):语义重放 → `--no-ff` 合并;冲突挂起。"""
|
|
1014
|
+
root = os.path.abspath(root or cg_main.root)
|
|
1015
|
+
G = git_dir_path or git_dir()
|
|
1016
|
+
S = shadow or shadow_dir()
|
|
1017
|
+
before = source_face_hashes(root)
|
|
1018
|
+
with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
|
|
1019
|
+
if not lk.acquired:
|
|
1020
|
+
return _busy("merge_cycle", timeout)
|
|
1021
|
+
cur = _git_ok(G, root, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
1022
|
+
if cur != BASE_BRANCH:
|
|
1023
|
+
return {"ok": False, "busy": False, "lock_held": True,
|
|
1024
|
+
"phase": "merge_cycle", "lock": lock_path(), "head": cur,
|
|
1025
|
+
"error": "当前 HEAD 在 %s,merge 须在 %s 上执行" % (cur, BASE_BRANCH)}
|
|
1026
|
+
rep = (replay_to_main(cg_main, S, delta or {}, accepted or [],
|
|
1027
|
+
batch=batch) if replay
|
|
1028
|
+
else {"written": [], "skipped": [], "failed": [], "entries": []})
|
|
1029
|
+
out = {"ok": True, "busy": False, "lock_held": True, "phase": "merge_cycle",
|
|
1030
|
+
"lock": lock_path(), "branch": branch, "replayed": rep}
|
|
1031
|
+
if rep["failed"]:
|
|
1032
|
+
out["ok"] = False
|
|
1033
|
+
return out
|
|
1034
|
+
# 语义重放结果落到主库工作树后,先入册 + 提交(**记录重放**,不是裁决)。
|
|
1035
|
+
# 入册面取**显式 rel 集**(新位置 ∪ 旧位置)而非 `pathspecs(root)`:
|
|
1036
|
+
# 层迁移会把源层目录搬空,`pathspecs` 滤空目录后就把「旧位置删除」漏掉,
|
|
1037
|
+
# 主库 HEAD 会留着旧文件 ⇒ 与影子分支的树不同(本机实测过这个漏)。
|
|
1038
|
+
ps = _stage_set(delta or {}, accepted or [])
|
|
1039
|
+
if ps:
|
|
1040
|
+
_git_ok(G, root, "add", "--", *ps)
|
|
1041
|
+
if _git(G, root, "diff", "--cached", "--quiet").returncode != 0:
|
|
1042
|
+
_git_ok(G, root, "commit", "-m",
|
|
1043
|
+
"sleep: %s 语义重放 Δ" % (batch or _stamp()))
|
|
1044
|
+
# **本轮内容所在的提交**(回退入口):按 §4.4 的次序,内容由
|
|
1045
|
+
# 「语义重放」先写进主库并提交,随后的 `--no-ff` 合并只是**拓扑记录**
|
|
1046
|
+
# (其树与第一父提交相同 ⇒ `git revert -m 1 <合并提交>` 对内容零影响,
|
|
1047
|
+
# 回退要指向本提交;ops 文档据此登记)。
|
|
1048
|
+
out["round_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
|
|
1049
|
+
out["replay_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
|
|
1050
|
+
# 仅当两侧树**完全相同**才合并(否则让 git 的文本自动合并去裁决就会绕过四闸)
|
|
1051
|
+
same = _git(G, root, "diff", "--quiet", branch, "HEAD").returncode == 0
|
|
1052
|
+
if not same:
|
|
1053
|
+
changed = [ln.strip() for ln in _git(
|
|
1054
|
+
G, root, "diff", "--name-only", branch, "HEAD").stdout.splitlines()
|
|
1055
|
+
if ln.strip()]
|
|
1056
|
+
out.update(deferred="tree_divergence",
|
|
1057
|
+
note="影子分支与重放后的主库树不同 ⇒ 挂起不合并(不自动解决)",
|
|
1058
|
+
conflict_ids=sorted({os.path.basename(p)[:-3]
|
|
1059
|
+
for p in changed}))
|
|
1060
|
+
out["source_face_delta"] = face_delta(before, source_face_hashes(root))
|
|
1061
|
+
return out
|
|
1062
|
+
r = _git(G, root, "merge", "--no-ff", branch, "-m",
|
|
1063
|
+
"sleep: merge %s into %s" % (branch, BASE_BRANCH))
|
|
1064
|
+
if r.returncode != 0:
|
|
1065
|
+
txt = (r.stdout + r.stderr).strip()
|
|
1066
|
+
out.update(ok=False, conflict="CONFLICT" in txt, error=txt[:500],
|
|
1067
|
+
hint=("冲突不自动解决(照 hive/wm.py 既有裁决):人工裁决后 "
|
|
1068
|
+
"git add + git commit 收口,或放弃本次合并"))
|
|
1069
|
+
out["source_face_delta"] = face_delta(before, source_face_hashes(root))
|
|
1070
|
+
return out
|
|
1071
|
+
out["merge_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
|
|
1072
|
+
out["merged"] = branch
|
|
1073
|
+
after = source_face_hashes(root)
|
|
1074
|
+
dlt = face_delta(before, after)
|
|
1075
|
+
out["source_face_delta"] = dlt
|
|
1076
|
+
out["main_written"] = dlt["added"] + dlt["changed"]
|
|
1077
|
+
return out
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
# ---------------- ② 迭代(在影子上跑 §3.1 的 ②–④)----------------
|
|
1081
|
+
|
|
1082
|
+
# 生效条件:sh_root 为影子目录时,在其上开一个 MdCGOS 并依次跑第②步归纳(consolidate.induce_memories)、第③步升层(consolidate.promote_memories)、第④步巩固与去污染(scrub.sweep,dry_run=not scrub_apply);返回 {step: 读数} 的字典。
|
|
1083
|
+
def iterate(sh_root: str, *, scrub_apply: bool = False) -> dict:
|
|
1084
|
+
"""②–④ 在**影子**上跑(§4.4 ②)——这三步都是确定性 / 可预演 / 可回滚的。
|
|
1085
|
+
|
|
1086
|
+
④ 的**实改**由 `MDCG_SLEEP_SCRUB_APPLY` 缺省关住(§3.3 的 ⚠ 档):
|
|
1087
|
+
缺省只在副本上盘点(dry_run),不落盘。
|
|
1088
|
+
"""
|
|
1089
|
+
from . import consolidate as _cd
|
|
1090
|
+
from . import scrub as _sc
|
|
1091
|
+
cg = _open_cg(sh_root)
|
|
1092
|
+
out = {}
|
|
1093
|
+
try:
|
|
1094
|
+
r2 = _cd.induce_memories(cg, apply=True)
|
|
1095
|
+
out["induce"] = {"written": r2.get("written"),
|
|
1096
|
+
"clusters": r2.get("clusters"),
|
|
1097
|
+
"note": r2.get("note")}
|
|
1098
|
+
r3 = _cd.promote_memories(sh_root, apply=True)
|
|
1099
|
+
out["promote"] = {"written": r3.get("written"),
|
|
1100
|
+
"promoted": (r3.get("promoted") or [])[:8],
|
|
1101
|
+
"targeted": r3.get("targeted"),
|
|
1102
|
+
"note": r3.get("note")}
|
|
1103
|
+
r4 = _sc.sweep(cg, dry_run=not scrub_apply)
|
|
1104
|
+
out["scrub"] = {"apply": bool(scrub_apply),
|
|
1105
|
+
"dry_run": r4.get("dry_run"),
|
|
1106
|
+
"n_issues": (r4.get("audit") or {}).get("n_issues"),
|
|
1107
|
+
"n_high_medium": r4.get("n_high_medium"),
|
|
1108
|
+
"applied": (r4.get("decontaminate") or {}).get("applied")}
|
|
1109
|
+
finally:
|
|
1110
|
+
try:
|
|
1111
|
+
cg.close()
|
|
1112
|
+
except Exception: # noqa: BLE001
|
|
1113
|
+
pass
|
|
1114
|
+
return out
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# 生效条件:给定九步台账的原始读数(scan / iterated / delta_digest / 相位 / 轮次 / 上一轮候选数 / skip_reason)时,**恒返回九条**按 STEPS 顺序的台账条目:①恒在(只读)、②③④在有 iterated 时并入读数否则记 skipped=skip_reason、⑤⑥恒记 skipped="未接线"、⑦记本轮 Δ 摘要与台账落点、⑧记候选数前后变化、⑨记方向性自检(只产记录,不自动触发修改)。
|
|
1118
|
+
def ledger_steps(*, scan: dict, iterated: dict = None, delta_digest: dict = None,
|
|
1119
|
+
phases: dict = None, round_index: int = 1,
|
|
1120
|
+
prev_candidates=None, skip_reason: str = "",
|
|
1121
|
+
self_check_every: int = SELF_CHECK_EVERY) -> list:
|
|
1122
|
+
"""九步台账(§3.1)——**逐步留读数**;⑤⑥ 恒标 `skipped: "未接线"`。"""
|
|
1123
|
+
it = iterated or {}
|
|
1124
|
+
steps = [dict(scan, step="recall_scan", name=STEP_NAMES["recall_scan"],
|
|
1125
|
+
readonly=True)]
|
|
1126
|
+
for key in ("induce", "promote", "scrub"):
|
|
1127
|
+
rec = dict(it.get(key) or {})
|
|
1128
|
+
rec.update(step=key, name=STEP_NAMES[key])
|
|
1129
|
+
if key not in it:
|
|
1130
|
+
rec["skipped"] = skip_reason or "本轮未迭代"
|
|
1131
|
+
steps.append(rec)
|
|
1132
|
+
for key in ("weights", "reindex"):
|
|
1133
|
+
steps.append({"step": key, "name": STEP_NAMES[key],
|
|
1134
|
+
"skipped": "未接线", "planned": NOT_WIRED[key]})
|
|
1135
|
+
steps.append({"step": "record", "name": STEP_NAMES["record"],
|
|
1136
|
+
"ledger": ledger_path(), "round": round_index,
|
|
1137
|
+
"delta": delta_digest or {"n": 0, "ids": [], "kinds": {}},
|
|
1138
|
+
"phases": phases or {}})
|
|
1139
|
+
cur = (scan or {}).get("candidates")
|
|
1140
|
+
steps.append({"step": "feedback", "name": STEP_NAMES["feedback"],
|
|
1141
|
+
"candidates": cur, "prev_round_candidates": prev_candidates,
|
|
1142
|
+
"delta": (cur - prev_candidates)
|
|
1143
|
+
if isinstance(cur, int) and isinstance(prev_candidates, int)
|
|
1144
|
+
else None})
|
|
1145
|
+
steps.append({"step": "self_check", "name": STEP_NAMES["self_check"],
|
|
1146
|
+
"round": round_index,
|
|
1147
|
+
"due": bool(round_index % int(self_check_every) == 0),
|
|
1148
|
+
"auto_apply": False,
|
|
1149
|
+
"note": ("方向性自检只产记录,**不自动触发修改**"
|
|
1150
|
+
"(智能论3.4.md:2282)")})
|
|
1151
|
+
return steps
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# 生效条件:给定 cg(主库)与各相位开关时,按 §3.2 的稳态判据与 §4.4 的四阶段跑一轮睡眠周期并返回台账记录;enabled=False / merge_mode="never" / 窗口外 / 候选数为 0 四种情形**只记账不动手**(②③④记 skipped);batch 为已合并批次时直接返回 {"idempotent": True} 且零动作。
|
|
1155
|
+
def run_cycle(cg=None, *, root: str = None, batch: str = None,
|
|
1156
|
+
enabled: bool = True, merge_mode: str = None,
|
|
1157
|
+
scrub_apply: bool = None, window: str = None,
|
|
1158
|
+
round_index: int = 1, now=None, git_dir_path: str = None,
|
|
1159
|
+
shadow: str = None, timeout: float = DEFAULT_LOCK_TIMEOUT) -> dict:
|
|
1160
|
+
"""一轮睡眠周期(§3.1 九步 + §4.4 四阶段)。"""
|
|
1161
|
+
root = os.path.abspath(root or (cg.root if cg is not None else mdcg_root()))
|
|
1162
|
+
if cg is None:
|
|
1163
|
+
cg = _open_cg(root)
|
|
1164
|
+
b = batch or _stamp()
|
|
1165
|
+
mode = merge_mode or sleep_merge_mode()
|
|
1166
|
+
scrub = sleep_scrub_apply() if scrub_apply is None else bool(scrub_apply)
|
|
1167
|
+
win = sleep_window() if window is None else window
|
|
1168
|
+
led = read_ledger()
|
|
1169
|
+
prev = None
|
|
1170
|
+
for r in reversed(led):
|
|
1171
|
+
if isinstance(r.get("candidates"), int):
|
|
1172
|
+
prev = r["candidates"]
|
|
1173
|
+
break
|
|
1174
|
+
rec = {"op": "sleep", "t": time.time(), "batch": b, "round": round_index,
|
|
1175
|
+
"root": root, "merge_mode": mode, "window": win,
|
|
1176
|
+
"scrub_apply": bool(scrub)}
|
|
1177
|
+
if batch_merged(b):
|
|
1178
|
+
rec.update(idempotent=True, merged=False, skipped="同批次已合并(幂等:零改动)",
|
|
1179
|
+
candidates=None, steps=ledger_steps(
|
|
1180
|
+
scan={}, skip_reason="幂等:同批次已合并",
|
|
1181
|
+
round_index=round_index, prev_candidates=prev))
|
|
1182
|
+
_append_ledger(rec)
|
|
1183
|
+
return rec
|
|
1184
|
+
|
|
1185
|
+
# ① 感知盘点(主库,只读)——稳态判据的口径
|
|
1186
|
+
from . import sustain as _sus
|
|
1187
|
+
ev = _sus.evolution_candidates(cg)
|
|
1188
|
+
scan = {"candidates": ev["candidates"], "ccg_backlog": ev["ccg_backlog"]["n"],
|
|
1189
|
+
"importance_drift": ev["importance_drift"]["n"],
|
|
1190
|
+
"by_fix": dict(ev.get("by_fix") or {})}
|
|
1191
|
+
rec["candidates"] = scan["candidates"]
|
|
1192
|
+
|
|
1193
|
+
gate_reason = None
|
|
1194
|
+
if not enabled:
|
|
1195
|
+
gate_reason = "MDCG_SLEEP=0(总开关关)"
|
|
1196
|
+
elif mode == "never":
|
|
1197
|
+
gate_reason = "MDCG_SLEEP_MERGE=never(只记账不迭代)"
|
|
1198
|
+
elif not in_window(win, now):
|
|
1199
|
+
gate_reason = "窗口外(MDCG_SLEEP_WINDOW=%s;只记账不迭代)" % win
|
|
1200
|
+
elif not scan["candidates"]:
|
|
1201
|
+
gate_reason = "稳态:盘点候选数为 0(只记账不动手)"
|
|
1202
|
+
|
|
1203
|
+
phases, iterated, delta, digest = {}, None, {}, {"n": 0, "ids": [], "kinds": {}}
|
|
1204
|
+
if gate_reason is None:
|
|
1205
|
+
m = materialize(root, git_dir_path=git_dir_path, shadow=shadow,
|
|
1206
|
+
ts=b, timeout=timeout)
|
|
1207
|
+
phases["materialize"] = {k: m.get(k) for k in
|
|
1208
|
+
("ok", "busy", "branch", "shadow", "git_dir",
|
|
1209
|
+
"baseline", "source_face_delta")}
|
|
1210
|
+
if m.get("ok"):
|
|
1211
|
+
S = m["shadow"]
|
|
1212
|
+
iterated = iterate(S, scrub_apply=scrub)
|
|
1213
|
+
phases["iterate"] = iterated
|
|
1214
|
+
rc = reconcile_gated(root, git_dir_path=m.get("git_dir"),
|
|
1215
|
+
shadow=S, branch=m.get("branch"), batch=b,
|
|
1216
|
+
baseline_face=(m.get("baseline_face") or {}),
|
|
1217
|
+
cg=cg, timeout=timeout)
|
|
1218
|
+
phases["reconcile"] = {k: rc.get(k) for k in
|
|
1219
|
+
("committed", "commit", "branch", "reason",
|
|
1220
|
+
"busy")}
|
|
1221
|
+
delta = rc.get("delta") or {}
|
|
1222
|
+
digest = {"n": len(delta), "ids": sorted(delta)[:20],
|
|
1223
|
+
"kinds": {k: sum(1 for v in delta.values() if v["kind"] == k)
|
|
1224
|
+
for k in ("added", "removed", "changed")}}
|
|
1225
|
+
rec["accepted"] = rc.get("accepted")
|
|
1226
|
+
rec["conflicts"] = rc.get("conflicts")
|
|
1227
|
+
rec["rejected"] = rc.get("rejected")
|
|
1228
|
+
if rc.get("committed") and mode in ("auto", "ask"):
|
|
1229
|
+
if mode == "ask":
|
|
1230
|
+
phases["merge"] = {"deferred": "ask",
|
|
1231
|
+
"note": "MDCG_SLEEP_MERGE=ask:合并留待人工"}
|
|
1232
|
+
else:
|
|
1233
|
+
mg = merge_cycle(cg, root, git_dir_path=m.get("git_dir"),
|
|
1234
|
+
branch=rc.get("branch"), shadow=S,
|
|
1235
|
+
delta=delta, accepted=rc.get("accepted"),
|
|
1236
|
+
batch=b, timeout=timeout)
|
|
1237
|
+
phases["merge"] = {k: mg.get(k) for k in
|
|
1238
|
+
("ok", "merged", "merge_commit",
|
|
1239
|
+
"round_commit", "replay_commit",
|
|
1240
|
+
"deferred", "conflict", "conflict_ids",
|
|
1241
|
+
"error", "main_written", "busy")}
|
|
1242
|
+
phases["merge"]["replayed"] = (mg.get("replayed") or {}).get("written")
|
|
1243
|
+
rec["merged"] = bool(mg.get("ok")) and not mg.get("deferred")
|
|
1244
|
+
if mg.get("deferred"):
|
|
1245
|
+
rec.setdefault("conflicts", [])
|
|
1246
|
+
rec["conflicts"] = list(rec.get("conflicts") or []) + [
|
|
1247
|
+
{"id": i, "gate": "concurrent",
|
|
1248
|
+
"reason": "影子分支与重放后的主库树不同(挂起不合并)"}
|
|
1249
|
+
for i in (mg.get("conflict_ids") or [])]
|
|
1250
|
+
else:
|
|
1251
|
+
phases["merge"] = ({"skipped": "本轮无提交(Δ 为空或未提交)"}
|
|
1252
|
+
if not rc.get("committed")
|
|
1253
|
+
else {"deferred": mode})
|
|
1254
|
+
|
|
1255
|
+
rec["steps"] = ledger_steps(scan=scan, iterated=iterated,
|
|
1256
|
+
delta_digest=digest, phases=phases,
|
|
1257
|
+
round_index=round_index, prev_candidates=prev,
|
|
1258
|
+
skip_reason=gate_reason or "")
|
|
1259
|
+
rec["skipped"] = gate_reason
|
|
1260
|
+
_append_ledger(rec)
|
|
1261
|
+
return rec
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
# 生效条件:argv 含 --status 时打印当前解析结果(git_dir / shadow / lock / pathspecs / tracking / history 四读数)的 JSON 并返回 0;argv 含 --audit 时只打印两条机械判据结论(不合规返回 1);无参数时打印解析结果。
|
|
1265
|
+
def _main(argv=None) -> int:
|
|
1266
|
+
ap = argparse.ArgumentParser(description="睡眠周期 git 机制(只读判读面)")
|
|
1267
|
+
ap.add_argument("--root", default=None, help="数据根(缺省 mdcg_root())")
|
|
1268
|
+
ap.add_argument("--git-dir", dest="git_dir_path", default=None)
|
|
1269
|
+
ap.add_argument("--shadow", default=None)
|
|
1270
|
+
ap.add_argument("--status", action="store_true", help="打印当前解析与判据读数")
|
|
1271
|
+
ap.add_argument("--audit", action="store_true", help="只跑两条机械判据")
|
|
1272
|
+
a = ap.parse_args(argv)
|
|
1273
|
+
root = os.path.abspath(a.root or mdcg_root())
|
|
1274
|
+
G = a.git_dir_path or git_dir()
|
|
1275
|
+
shadow = a.shadow or shadow_dir()
|
|
1276
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
1277
|
+
if a.audit:
|
|
1278
|
+
tr = audit_tracking(root, git_dir_path=G)
|
|
1279
|
+
hi = audit_history(root, git_dir_path=G)
|
|
1280
|
+
print(json.dumps({"tracking": tr, "history": hi}, ensure_ascii=False,
|
|
1281
|
+
indent=2))
|
|
1282
|
+
return 0 if (tr["ok"] and hi["ok"]) else 1
|
|
1283
|
+
out = {"root": root, "git_dir": G, "shadow": shadow, "lock": lock_path(),
|
|
1284
|
+
"lock_exists": os.path.exists(lock_path() + ".lock"),
|
|
1285
|
+
"git_dir_exists": os.path.isdir(G),
|
|
1286
|
+
"shadow_exists": os.path.isdir(shadow),
|
|
1287
|
+
"pathspecs": pathspecs(root),
|
|
1288
|
+
"source_face_md": len(source_face_hashes(root))}
|
|
1289
|
+
if os.path.isdir(G):
|
|
1290
|
+
out["tracking"] = audit_tracking(root, git_dir_path=G)
|
|
1291
|
+
out["history"] = audit_history(root, git_dir_path=G)
|
|
1292
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
1293
|
+
return 0
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
if __name__ == "__main__":
|
|
1297
|
+
sys.exit(_main())
|