@furongjun1999/dsh-memory 0.7.4 → 0.8.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.
Files changed (46) hide show
  1. package/README.md +632 -630
  2. package/codebuddy/CODEBUDDY.md +5 -5
  3. package/docs/discipline/templates/zcode-user.md.tmpl +4 -1
  4. package/docs/eval/issue64_replay_check_/346/240/270/351/252/214/344/270/216/344/277/256/345/244/215_v1.0.md +284 -0
  5. package/docs/eval//345/217/221/345/270/20312_/344/270/226/347/225/214/346/250/241/345/236/213/345/212/237/350/203/275/347/253/257_v1.0.md +1 -1
  6. package/docs/eval//345/217/221/345/270/20313_/344/270/212/344/270/213/346/226/207/350/207/252/347/256/241/347/220/206/346/234/272/345/210/266_v1.0.md +114 -0
  7. package/docs/eval//345/217/221/345/270/20314_/350/272/253/344/275/223/303/227/350/204/221/347/273/204/345/220/210_v1.0.md +152 -0
  8. package/docs/eval//351/262/270/345/250/230/344/270/203/347/261/273/347/212/266/346/200/201/350/277/275/350/270/252_v1.0.md +176 -0
  9. package/docs/mdcg//344/270/226/347/225/214/346/250/241/345/236/213/345/212/237/350/203/275/347/253/257_/344/275/277/347/224/250/344/270/216/350/277/220/347/273/264_v1.0.md +92 -13
  10. 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 +43 -43
  11. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +14 -3
  12. 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 +85 -1
  13. package/docs/plans//347/201/265/346/236/242/350/272/253/344/275/223/303/227/350/204/221_/344/270/226/347/225/214/346/250/241/345/236/213/345/257/271/346/216/245/350/256/276/350/256/241_v0.1.md +119 -0
  14. 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 +461 -461
  15. package/docs//350/256/260/345/277/206/347/263/273/347/273/237/345/215/263/347/231/275/347/256/261/345/214/226/346/231/272/350/203/275_/346/236/266/346/236/204/345/257/271/347/205/247_v0.2.md +121 -0
  16. package/docs//350/256/260/345/277/206/347/263/273/347/273/237/345/215/263/347/231/275/347/256/261/345/214/226/346/231/272/350/203/275_/346/236/266/346/236/204/345/257/271/347/205/247_v0.3.md +117 -0
  17. package/lib/hooks.d.ts +34 -0
  18. package/lib/hooks.js +165 -0
  19. package/lib/index.js +22 -1
  20. package/lib/lib/mdcg_client.d.ts +37 -0
  21. package/lib/lib/mdcg_client.js +44 -0
  22. package/md_cg/audit.py +7 -1
  23. package/md_cg/bench_governance.py +4 -1
  24. package/md_cg/bench_locomo_zh.py +12 -2
  25. package/md_cg/bench_zh_mad.py +12 -2
  26. package/md_cg/chain.py +23 -7
  27. package/md_cg/consolidate.py +81 -10
  28. package/md_cg/interop.py +25 -9
  29. package/md_cg/lifecycle.py +7 -0
  30. package/md_cg/mcp_server.py +22 -0
  31. package/md_cg/mdcg.py +6 -1
  32. package/md_cg/scrub.py +4 -2
  33. package/md_cg/sleep.py +273 -103
  34. package/md_cg/sustain.py +150 -4
  35. package/md_cg/test_lifecycle_retire_leak.py +90 -0
  36. package/md_cg/test_p6_consolidate.py +389 -7
  37. package/md_cg/test_sleep_gitlock.py +134 -0
  38. package/md_cg/test_spatial_coords3d.py +292 -0
  39. package/md_cg/test_sustain_bounded.py +624 -0
  40. package/md_cg/units.py +4 -1
  41. package/package.json +2 -2
  42. package/skills/plugin.json +1 -1
  43. package/src/hooks.ts +177 -0
  44. package/src/index.ts +22 -1
  45. package/src/lib/mdcg_client.ts +48 -0
  46. package/zcode/AGENTS.md +5 -5
package/md_cg/sleep.py CHANGED
@@ -75,6 +75,18 @@ GIT_USER_EMAIL = "sleep@lingshu.local"
75
75
  FORBIDDEN_BASENAMES = ("_keys.json", "_access.log", "_index.json")
76
76
  FORBIDDEN_SUFFIXES = (".tmp", ".lock")
77
77
  DEFAULT_LOCK_TIMEOUT = 30.0
78
+ #: 单条 git 子进程的**硬超时**(秒)——issue #63 主修(单点可调)。
79
+ #: 取值依据:本机 git 操作为秒级(init / add / commit / rev-parse / worktree /
80
+ #: merge / revert 全是单库本地动作),120s 为安全上界;超时即 kill
81
+ #: (`subprocess.run(timeout=)` 语义);残留锁处置见 `_clear_git_lock`
82
+ #: (有界自清,2026-10-06 落地原「是否自动清理」待裁项)。
83
+ _GIT_TIMEOUT_S = 120
84
+ #: git 超时被 kill 后可能的残留锁名(有界自清的**唯一**对象名)。
85
+ _GIT_LOCK_NAME = "index.lock"
86
+ #: 残留锁的**超龄阈值**(秒)——有界自清的第二闸:本模块全部 git 调用都带
87
+ #: `_GIT_TIMEOUT_S` 超时,合法操作结构性不可能持锁超过它;300s(≈2.5×)之上
88
+ #: 仍存在的锁只可能是崩溃/被 kill 的残留。
89
+ _GIT_LOCK_STALE_S = 300
78
90
  #: 睡眠轮次的**台账**(append-only JSONL)——落 `sleep_root()` 侧(状态面,
79
91
  #: **不是数据根**):台账是运行态,不该成为真源面的一部分、也不该进版本库。
80
92
  SLEEP_LEDGER = "_sleep.jsonl"
@@ -116,6 +128,16 @@ class SleepError(Exception):
116
128
  """git 操作失败(带 stderr 摘要)。"""
117
129
 
118
130
 
131
+ class SleepGitTimeout(SleepError):
132
+ """git 子进程**超时已被 kill**(issue #63)——与「git 返回非零」区分。
133
+
134
+ 为什么单列一支:超时与普通失败的处理面不同——普通失败(rc≠0)是 git
135
+ 对本次操作的裁决(冲突 / 非法状态),超时是**本次操作没有完成**(可能
136
+ 留下半成品与残留锁),台账文案必须能分开(物化超时 vs 物化失败)。
137
+ `SleepError` 的子类 ⇒ 所有既有 `except SleepError` 面照旧收得住。
138
+ """
139
+
140
+
119
141
  # 生效条件:name 为 SLEEP_ENV_DEFAULTS 的键时,按 SLEEP_ENV_KEYS[name] 从 environ(缺省 os.environ)取名取值,缺键(None)时回落真源缺省;返回**原始字符串**(不做 bool/float 解释——解释归各具名包装)。name 非表内键时抛 KeyError(不做静默回落:拼错键名即编程错误)。
120
142
  def sleep_env(name: str, environ=None) -> str:
121
143
  """§4.7 env 表的唯一读取出口(原始字面量)。"""
@@ -265,23 +287,93 @@ def face_delta(before: dict, after: dict) -> dict:
265
287
  if before[k] != after[k])}
266
288
 
267
289
 
268
- # 生效条件: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 非零不抛(由调用方判)。
290
+ # 生效条件: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 + **timeout=_GIT_TIMEOUT_S** + **stdin=DEVNULL**)并返回 CompletedProcess;returncode 非零不抛(由调用方判);TimeoutExpired **不吞**——单点转 SleepGitTimeout(消息含「超时(_GIT_TIMEOUT_S)已被 kill」+ 命令名 + 残留锁人工核删指引)。
291
+ # 生效条件:git_dir_path 下存在 index.lock 且(force 为真 或 其 mtime 距今 ≥ _GIT_LOCK_STALE_S 秒)时删除该文件、记一行 `git_lock_cleared` 台账(写入失败不阻断)并返回 "cleared";锁不存在返回 "absent";存在但未达清理条件、或删除失败(OSError)返回 "left"(保持既有的人工核删指引面)。
292
+ def _clear_git_lock(git_dir_path: str, *, force: bool = False) -> str:
293
+ """有界自清影子仓的 git 残留锁(三闸口径,2026-10-06 落地待裁项)。
294
+
295
+ 背景(issue #63 台账「是否自动清理」):`_git` 超时 kill 自己的 git 后,
296
+ 影子仓可能留下 `index.lock`,此前只给人工核删指引。现**有界**自清:
297
+
298
+ ① **范围闸**:只碰本模块影子仓的 `<git_dir>/index.lock`——该仓由本模块
299
+ 单属、轮级独占,无第三方 git 的合法持锁面。(**调用约定面**:影子仓
300
+ 归属由调用方传入的 git_dir 保证;本函数机械面只保证对象名恒为
301
+ `<git_dir>/index.lock` 且须过②闸——独立复核 2026-10-06 观察 1 如实
302
+ 标注。)
303
+ ② **时机/年龄闸**:`force=True` 仅在本模块**刚 kill 掉自己超时的 git
304
+ 子进程**后调用(此刻仓内任何锁只可能来自该进程或其更早残留);
305
+ 非 force(每次 git 调用前的巡检)只在锁 **mtime 距今 ≥
306
+ `_GIT_LOCK_STALE_S`** 时清理(≫ 全部调用的 120s 超时上界,合法操作
307
+ 结构性不可能持锁这么久);
308
+ ③ **其余一律不删**(返回 "left"),异常消息保留人工核删指引。
309
+
310
+ 返回 "cleared" / "left" / "absent"。
311
+ """
312
+ lock = os.path.join(git_dir_path, _GIT_LOCK_NAME)
313
+ try:
314
+ st = os.stat(lock)
315
+ except OSError:
316
+ return "absent"
317
+ age = max(0.0, time.time() - st.st_mtime)
318
+ if not force and age < _GIT_LOCK_STALE_S:
319
+ return "left"
320
+ try:
321
+ os.remove(lock)
322
+ except OSError:
323
+ return "left"
324
+ try:
325
+ _append_ledger({"event": "git_lock_cleared", "lock": lock,
326
+ "age_s": round(age, 1), "force": bool(force)})
327
+ except OSError: # 台账失败不阻断 git 主链(与审计面同风格)
328
+ pass
329
+ return "cleared"
330
+
331
+
269
332
  def _git(git_dir_path: str, work_tree: str, *args: str) -> subprocess.CompletedProcess:
270
333
  """git 调用的**唯一出口**:`--git-dir` 与 `--work-tree` 一律显式给。
271
334
 
272
335
  `--work-tree` 不是装饰:物化与合并传数据根,对账+提交传影子——同一个版本库
273
336
  两个工作树,靠这两个显式开关切换,**不依赖进程 cwd**(`hive/wm.py` 用 `-C`
274
337
  是因为它的工作树就是版本库本身;这里两者分居,故用分裂形态)。
338
+
339
+ **有界化(issue #63)**——四件套缺一不可:
340
+
341
+ ① `timeout=_GIT_TIMEOUT_S`:子进程**无界等待**是常驻循环停摆的直接成因
342
+ (实测停 9.5 小时:`SustainLoop` 单线程串行六档,任一档挂住即全停)。
343
+ ② `stdin=subprocess.DEVNULL`:不指定 stdin 时子进程**继承父进程 stdin**
344
+ ——DSH 常驻宿主的 stdin 是 **JSON-RPC 活管道**(不写入也不关闭),
345
+ 任何读 stdin 的子进程会永久悬挂(实测:活管道下 6s 仍 poll=None;
346
+ DEVNULL 下 0.02s 立即 EOF)。先例形态:`md_cg/run_tests.py:105`。
347
+ ③ `TimeoutExpired` **不吞**:转 `SleepGitTimeout`(`SleepError` 子类,
348
+ 既有 `except SleepError` 面照旧收得住),消息带命令名与残留锁处置读数。
349
+ ④ **残留锁有界自清**(2026-10-06 落地待裁项):调用前置巡检清超龄残留
350
+ (崩溃场景),超时 kill 后 `force` 清当前残留(见 `_clear_git_lock`
351
+ 三闸口径)——消息里如实带「已清理/未清理」。
275
352
  """
276
353
  argv = ["git", "--git-dir=" + git_dir_path, "--work-tree=" + work_tree]
277
354
  argv.extend(args)
278
355
  env = dict(os.environ, PYTHONUTF8="1")
279
- return subprocess.run(argv, capture_output=True, text=True,
280
- encoding="utf-8", errors="replace",
281
- env=env, shell=False)
282
-
283
-
284
- # 生效条件:同 _git 的调用形态;returncode != 0 时抛 SleepError(消息取 stderr 为真值、为空则取 stdout,strip 后截 300 字符),为 0 时返回 stdout。
356
+ # 前置巡检:清超龄残留锁(有界自清第一时机——崩溃残留,无 handler 可依)
357
+ _clear_git_lock(git_dir_path)
358
+ try:
359
+ return subprocess.run(argv, capture_output=True, text=True,
360
+ encoding="utf-8", errors="replace",
361
+ env=env, shell=False,
362
+ timeout=_GIT_TIMEOUT_S,
363
+ stdin=subprocess.DEVNULL)
364
+ except subprocess.TimeoutExpired as exc:
365
+ # 第二时机:本模块刚 kill 自己的 git——此刻仓内任何锁只可能是残留
366
+ _lock_state = _clear_git_lock(git_dir_path, force=True)
367
+ raise SleepGitTimeout(
368
+ "git %s 超时(%ss)已被 kill(命令:git %s)——"
369
+ "本次 git 操作未完成;残留锁处置:%s(%s)"
370
+ % (" ".join(args[:2]), _GIT_TIMEOUT_S, " ".join(args[:6]),
371
+ {"cleared": "已自动清理", "left": "未动,若后续报 lock 请人工核删",
372
+ "absent": "无残留锁"}.get(_lock_state, _lock_state),
373
+ os.path.join(git_dir_path, _GIT_LOCK_NAME))) from exc
374
+
375
+
376
+ # 生效条件:同 _git 的调用形态;returncode != 0 时抛 SleepError(消息取 stderr 为真值、为空则取 stdout,strip 后截 300 字符),为 0 时返回 stdout;_git 侧超时以 SleepGitTimeout(SleepError 子类,消息含「超时…已被 kill」)原样冒出——两条失败面**可区分**(rc≠0 = git 的裁决;超时 = 操作没完成)。
285
377
  def _git_ok(git_dir_path: str, work_tree: str, *args: str) -> str:
286
378
  r = _git(git_dir_path, work_tree, *args)
287
379
  if r.returncode != 0:
@@ -380,26 +472,36 @@ def materialize(root: str = None, *, git_dir_path: str = None,
380
472
  with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
381
473
  if not lk.acquired:
382
474
  return _busy("materialize", timeout)
383
- rep = ensure_repo(root, G)
384
- init = _stage_and_commit(G, root, "sleep: 基线(真源面入册)") \
385
- if not _has_head(G, root) else {"committed": False,
386
- "reason": "已有基线提交"}
387
- if os.path.isdir(S):
388
- r = _git(G, root, "worktree", "remove", "--force", S)
475
+ try:
476
+ rep = ensure_repo(root, G)
477
+ init = _stage_and_commit(G, root, "sleep: 基线(真源面入册)") \
478
+ if not _has_head(G, root) else {"committed": False,
479
+ "reason": "已有基线提交"}
480
+ if os.path.isdir(S):
481
+ r = _git(G, root, "worktree", "remove", "--force", S)
482
+ if r.returncode != 0:
483
+ # 非注册工作树(上一次运行被杀、目录残留)→ 直接摘目录
484
+ shutil.rmtree(S, ignore_errors=True)
485
+ r = _git(G, root, "worktree", "add", "-b", branch, S, BASE_BRANCH)
389
486
  if r.returncode != 0:
390
- # 非注册工作树(上一次运行被杀、目录残留)→ 直接摘目录
391
- shutil.rmtree(S, ignore_errors=True)
392
- r = _git(G, root, "worktree", "add", "-b", branch, S, BASE_BRANCH)
393
- if r.returncode != 0:
394
- r2 = _git(G, root, "worktree", "add", S, branch)
395
- if r2.returncode != 0:
396
- return {"ok": False, "busy": False, "lock_held": True,
397
- "phase": "materialize", "lock": lock_path(),
398
- "error": (r2.stderr or r2.stdout).strip()[:300],
399
- "hint": "影子分支/目录未能挂上"}
400
- reused = True
401
- else:
402
- reused = False
487
+ r2 = _git(G, root, "worktree", "add", S, branch)
488
+ if r2.returncode != 0:
489
+ return {"ok": False, "busy": False, "lock_held": True,
490
+ "phase": "materialize", "lock": lock_path(),
491
+ "error": (r2.stderr or r2.stdout).strip()[:300],
492
+ "hint": "影子分支/目录未能挂上"}
493
+ reused = True
494
+ else:
495
+ reused = False
496
+ except SleepGitTimeout as e:
497
+ # 超时与「物化失败」分开记账(issue #63):超时 = git 操作**没完成**
498
+ # (已被 kill),与 rc≠0 的「git 的裁决失败」不是一回事——
499
+ # run_cycle 据此写「物化超时:…」(见 mat_fail 分流)。
500
+ return {"ok": False, "busy": False, "lock_held": True,
501
+ "phase": "materialize", "lock": lock_path(),
502
+ "timeout": True, "error": str(e),
503
+ "hint": ("git 子进程超时已被 kill:本轮物化放弃;"
504
+ "残留锁核删指引见 error")}
403
505
  after = source_face_hashes(root)
404
506
  delta = face_delta(before, after)
405
507
  # ok = **物化动作成功**(影子已挂上);真源面漂移与否是另一个读数
@@ -446,9 +548,17 @@ def reconcile_and_commit(root: str = None, *, git_dir_path: str = None,
446
548
  "error": "影子工作树不存在:先 materialize", "shadow": S}
447
549
  # 链式工作树用自己的 gitdir(否则 HEAD/index 取主那一份,提交会落到 main)
448
550
  GS = worktree_git_dir(S) or G
449
- br = branch or _git_ok(GS, S, "rev-parse", "--abbrev-ref", "HEAD").strip()
450
- msg = "sleep: %s 轮对账提交" % (batch or _stamp())
451
- res = _stage_and_commit(GS, S, msg)
551
+ try:
552
+ br = branch or _git_ok(GS, S, "rev-parse", "--abbrev-ref",
553
+ "HEAD").strip()
554
+ msg = "sleep: %s 轮对账提交" % (batch or _stamp())
555
+ res = _stage_and_commit(GS, S, msg)
556
+ except SleepGitTimeout as e:
557
+ # 超时(issue #63):本阶段未完成——与「Δ 为空/未提交」等读数分开报。
558
+ return {"ok": False, "busy": False, "lock_held": True,
559
+ "phase": "reconcile_and_commit", "git_dir": G,
560
+ "worktree_git_dir": GS, "shadow": S, "timeout": True,
561
+ "error": str(e), "lock": lock_path()}
452
562
  after = source_face_hashes(root)
453
563
  delta = face_delta(before, after)
454
564
  return {"ok": not (delta["added"] or delta["removed"] or delta["changed"]),
@@ -481,27 +591,40 @@ def merge(root: str = None, *, git_dir_path: str = None, branch: str,
481
591
  with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
482
592
  if not lk.acquired:
483
593
  return _busy("merge", timeout)
484
- cur = _git_ok(G, root, "rev-parse", "--abbrev-ref", "HEAD").strip()
485
- if cur != BASE_BRANCH:
486
- return {"ok": False, "busy": False, "lock_held": True, "phase": "merge",
487
- "lock": lock_path(), "head": cur,
488
- "error": "当前 HEAD 在 %s,merge 须在 %s 上执行" % (cur, BASE_BRANCH)}
489
- r = _git(G, root, "merge", "--no-ff", branch, "-m",
490
- "sleep: merge %s into %s" % (branch, BASE_BRANCH))
491
- if r.returncode != 0:
492
- out = (r.stdout + r.stderr).strip()
493
- return {"ok": False, "busy": False, "lock_held": True, "phase": "merge",
494
- "lock": lock_path(), "shadow": S,
495
- "conflict": "CONFLICT" in out, "error": out[:500],
496
- "source_face_delta": face_delta(before, source_face_hashes(root)),
497
- "hint": ("冲突不自动解决(照 hive/wm.py 既有裁决):人工/LLM 裁决后 "
498
- "git add + git commit 收口,或 git merge --abort 放弃本次合并")}
594
+ try:
595
+ cur = _git_ok(G, root, "rev-parse", "--abbrev-ref", "HEAD").strip()
596
+ if cur != BASE_BRANCH:
597
+ return {"ok": False, "busy": False, "lock_held": True,
598
+ "phase": "merge", "lock": lock_path(), "head": cur,
599
+ "error": "当前 HEAD 在 %s,merge 须在 %s 上执行"
600
+ % (cur, BASE_BRANCH)}
601
+ r = _git(G, root, "merge", "--no-ff", branch, "-m",
602
+ "sleep: merge %s into %s" % (branch, BASE_BRANCH))
603
+ if r.returncode != 0:
604
+ out = (r.stdout + r.stderr).strip()
605
+ return {"ok": False, "busy": False, "lock_held": True,
606
+ "phase": "merge", "lock": lock_path(), "shadow": S,
607
+ "conflict": "CONFLICT" in out, "error": out[:500],
608
+ "source_face_delta": face_delta(before,
609
+ source_face_hashes(root)),
610
+ "hint": ("冲突不自动解决(照 hive/wm.py 既有裁决):"
611
+ "人工/LLM 裁决后 git add + git commit 收口,"
612
+ "或 git merge --abort 放弃本次合并")}
613
+ head = _git_ok(G, root, "rev-parse", "HEAD").strip()
614
+ except SleepGitTimeout as e:
615
+ # 超时(issue #63):合并**未完成**(不是冲突、也不是被拒)——
616
+ # 单独一档读数,冲突裁决路径不受影响。
617
+ return {"ok": False, "busy": False, "lock_held": True,
618
+ "phase": "merge", "lock": lock_path(), "shadow": S,
619
+ "timeout": True, "error": str(e),
620
+ "hint": ("git 子进程超时已被 kill:合并未完成;"
621
+ "残留锁核删指引见 error")}
499
622
  after = source_face_hashes(root)
500
623
  delta = face_delta(before, after)
501
624
  return {"ok": True, "busy": False, "lock_held": True, "phase": "merge",
502
- "lock": lock_path(), "merged": branch,
503
- "commit": _git_ok(G, root, "rev-parse", "HEAD").strip(),
504
- "source_face_delta": delta, "main_written": delta["added"] + delta["changed"]}
625
+ "lock": lock_path(), "merged": branch, "commit": head,
626
+ "source_face_delta": delta,
627
+ "main_written": delta["added"] + delta["changed"]}
505
628
 
506
629
 
507
630
  # 生效条件: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,不提供抹历史的强推档**。
@@ -521,22 +644,31 @@ def revert(commit: str, root: str = None, *, git_dir_path: str = None,
521
644
  with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
522
645
  if not lk.acquired:
523
646
  return _busy("revert", timeout)
524
- parents = _git_ok(G, root, "rev-list", "--parents", "-n1", commit).split()
525
- args = ["revert", "--no-edit"]
526
- if len(parents) > 2:
527
- args += ["-m", "1"]
528
- args.append(commit)
529
- r = _git(G, root, *args)
530
- if r.returncode != 0:
531
- out = (r.stdout + r.stderr).strip()
532
- return {"ok": False, "busy": False, "lock_held": True, "phase": "revert",
533
- "lock": lock_path(), "conflict": "CONFLICT" in out,
534
- "error": out[:500],
535
- "hint": "冲突不自动解决;处理后可 git revert --continue / --abort"}
647
+ try:
648
+ parents = _git_ok(G, root, "rev-list", "--parents", "-n1",
649
+ commit).split()
650
+ args = ["revert", "--no-edit"]
651
+ if len(parents) > 2:
652
+ args += ["-m", "1"]
653
+ args.append(commit)
654
+ r = _git(G, root, *args)
655
+ if r.returncode != 0:
656
+ out = (r.stdout + r.stderr).strip()
657
+ return {"ok": False, "busy": False, "lock_held": True,
658
+ "phase": "revert", "lock": lock_path(),
659
+ "conflict": "CONFLICT" in out, "error": out[:500],
660
+ "hint": ("冲突不自动解决;处理后可 git revert --continue "
661
+ "/ --abort")}
662
+ head = _git_ok(G, root, "rev-parse", "HEAD").strip()
663
+ except SleepGitTimeout as e:
664
+ return {"ok": False, "busy": False, "lock_held": True,
665
+ "phase": "revert", "lock": lock_path(), "timeout": True,
666
+ "error": str(e),
667
+ "hint": ("git 子进程超时已被 kill:回滚未完成;"
668
+ "残留锁核删指引见 error")}
536
669
  after = source_face_hashes(root)
537
670
  return {"ok": True, "busy": False, "lock_held": True, "phase": "revert",
538
- "lock": lock_path(), "reverted": commit,
539
- "commit": _git_ok(G, root, "rev-parse", "HEAD").strip(),
671
+ "lock": lock_path(), "reverted": commit, "commit": head,
540
672
  "merge_commit": len(parents) > 2,
541
673
  "source_face_delta": face_delta(before, after)}
542
674
 
@@ -981,10 +1113,20 @@ def reconcile_gated(root: str = None, *, git_dir_path: str = None,
981
1113
  gated = gate_delta(cgm, delta, baseline_face=baseline_face,
982
1114
  shadow_root=S)
983
1115
  GS = worktree_git_dir(S) or G
984
- br = branch or _git_ok(GS, S, "rev-parse", "--abbrev-ref", "HEAD").strip()
985
- rels = _stage_set(delta, gated["accepted"])
986
- res = _commit_paths(GS, S, rels,
987
- "sleep: %s 轮对账提交" % (batch or _stamp()))
1116
+ try:
1117
+ br = branch or _git_ok(GS, S, "rev-parse", "--abbrev-ref",
1118
+ "HEAD").strip()
1119
+ rels = _stage_set(delta, gated["accepted"])
1120
+ res = _commit_paths(GS, S, rels,
1121
+ "sleep: %s 轮对账提交" % (batch or _stamp()))
1122
+ except SleepGitTimeout as e:
1123
+ # 超时(issue #63):四闸裁决读数(delta / accepted / conflicts /
1124
+ # rejected)照带——只是入册没完成。
1125
+ return {"ok": False, "busy": False, "lock_held": True,
1126
+ "phase": "reconcile_gated", "git_dir": G,
1127
+ "worktree_git_dir": GS, "shadow": S, "timeout": True,
1128
+ "error": str(e), "lock": lock_path(),
1129
+ "delta": delta, **gated}
988
1130
  after = source_face_hashes(root)
989
1131
  return {"ok": True, "busy": False, "lock_held": True,
990
1132
  "phase": "reconcile_gated", "git_dir": G, "worktree_git_dir": GS,
@@ -1061,7 +1203,14 @@ def merge_cycle(cg_main, root: str = None, *, git_dir_path: str = None,
1061
1203
  with FileLock(lock_path(), timeout=timeout, strict=False) as lk:
1062
1204
  if not lk.acquired:
1063
1205
  return _busy("merge_cycle", timeout)
1064
- cur = _git_ok(G, root, "rev-parse", "--abbrev-ref", "HEAD").strip()
1206
+ try:
1207
+ cur = _git_ok(G, root, "rev-parse", "--abbrev-ref", "HEAD").strip()
1208
+ except SleepGitTimeout as e:
1209
+ return {"ok": False, "busy": False, "lock_held": True,
1210
+ "phase": "merge_cycle", "lock": lock_path(),
1211
+ "branch": branch, "timeout": True, "error": str(e),
1212
+ "hint": ("git 子进程超时已被 kill:合并尚未开始;"
1213
+ "残留锁核删指引见 error")}
1065
1214
  if cur != BASE_BRANCH:
1066
1215
  return {"ok": False, "busy": False, "lock_held": True,
1067
1216
  "phase": "merge_cycle", "lock": lock_path(), "head": cur,
@@ -1079,39 +1228,54 @@ def merge_cycle(cg_main, root: str = None, *, git_dir_path: str = None,
1079
1228
  # 层迁移会把源层目录搬空,`pathspecs` 滤空目录后就把「旧位置删除」漏掉,
1080
1229
  # 主库 HEAD 会留着旧文件 ⇒ 与影子分支的树不同(本机实测过这个漏)。
1081
1230
  ps = _stage_set(delta or {}, accepted or [])
1082
- if ps:
1083
- _git_ok(G, root, "add", "--", *ps)
1084
- if _git(G, root, "diff", "--cached", "--quiet").returncode != 0:
1085
- _git_ok(G, root, "commit", "-m",
1086
- "sleep: %s 语义重放 Δ" % (batch or _stamp()))
1087
- # **本轮内容所在的提交**(回退入口):按 §4.4 的次序,内容由
1088
- # 「语义重放」先写进主库并提交,随后的 `--no-ff` 合并只是**拓扑记录**
1089
- # (其树与第一父提交相同 ⇒ `git revert -m 1 <合并提交>` 对内容零影响,
1090
- # 回退要指向本提交;ops 文档据此登记)。
1091
- out["round_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
1092
- out["replay_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
1093
- # 仅当两侧树**完全相同**才合并(否则让 git 的文本自动合并去裁决就会绕过四闸)
1094
- same = _git(G, root, "diff", "--quiet", branch, "HEAD").returncode == 0
1095
- if not same:
1096
- changed = [ln.strip() for ln in _git(
1097
- G, root, "diff", "--name-only", branch, "HEAD").stdout.splitlines()
1098
- if ln.strip()]
1099
- out.update(deferred="tree_divergence",
1100
- note="影子分支与重放后的主库树不同 ⇒ 挂起不合并(不自动解决)",
1101
- conflict_ids=sorted({os.path.basename(p)[:-3]
1102
- for p in changed}))
1103
- out["source_face_delta"] = face_delta(before, source_face_hashes(root))
1104
- return out
1105
- r = _git(G, root, "merge", "--no-ff", branch, "-m",
1106
- "sleep: merge %s into %s" % (branch, BASE_BRANCH))
1107
- if r.returncode != 0:
1108
- txt = (r.stdout + r.stderr).strip()
1109
- out.update(ok=False, conflict="CONFLICT" in txt, error=txt[:500],
1110
- hint=("冲突不自动解决(照 hive/wm.py 既有裁决):人工裁决后 "
1111
- "git add + git commit 收口,或放弃本次合并"))
1231
+ try:
1232
+ if ps:
1233
+ _git_ok(G, root, "add", "--", *ps)
1234
+ if _git(G, root, "diff", "--cached", "--quiet").returncode != 0:
1235
+ _git_ok(G, root, "commit", "-m",
1236
+ "sleep: %s 语义重放 Δ" % (batch or _stamp()))
1237
+ # **本轮内容所在的提交**(回退入口):按 §4.4 的次序,内容由
1238
+ # 「语义重放」先写进主库并提交,随后的 `--no-ff` 合并只是**拓扑记录**
1239
+ # (其树与第一父提交相同 ⇒ `git revert -m 1 <合并提交>` 对内容零影响,
1240
+ # 回退要指向本提交;ops 文档据此登记)。
1241
+ out["round_commit"] = _git_ok(G, root, "rev-parse",
1242
+ "HEAD").strip()
1243
+ out["replay_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
1244
+ # 仅当两侧树**完全相同**才合并(否则让 git 的文本自动合并去裁决就会绕过四闸)
1245
+ same = _git(G, root, "diff", "--quiet", branch,
1246
+ "HEAD").returncode == 0
1247
+ if not same:
1248
+ changed = [ln.strip() for ln in _git(
1249
+ G, root, "diff", "--name-only", branch,
1250
+ "HEAD").stdout.splitlines() if ln.strip()]
1251
+ out.update(deferred="tree_divergence",
1252
+ note=("影子分支与重放后的主库树不同 ⇒ 挂起不合并"
1253
+ "(不自动解决)"),
1254
+ conflict_ids=sorted({os.path.basename(p)[:-3]
1255
+ for p in changed}))
1256
+ out["source_face_delta"] = face_delta(before,
1257
+ source_face_hashes(root))
1258
+ return out
1259
+ r = _git(G, root, "merge", "--no-ff", branch, "-m",
1260
+ "sleep: merge %s into %s" % (branch, BASE_BRANCH))
1261
+ if r.returncode != 0:
1262
+ txt = (r.stdout + r.stderr).strip()
1263
+ out.update(ok=False, conflict="CONFLICT" in txt, error=txt[:500],
1264
+ hint=("冲突不自动解决(照 hive/wm.py 既有裁决):"
1265
+ "人工裁决后 git add + git commit 收口,"
1266
+ "或放弃本次合并"))
1267
+ out["source_face_delta"] = face_delta(before,
1268
+ source_face_hashes(root))
1269
+ return out
1270
+ out["merge_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
1271
+ except SleepGitTimeout as e:
1272
+ # 超时(issue #63):重放后的入册/合并**未完成**(既非冲突、也非
1273
+ # 树分歧)——单独一档读数,与上面两条裁决路径分开。
1274
+ out.update(ok=False, timeout=True, error=str(e),
1275
+ hint=("git 子进程超时已被 kill:入册/合并未完成;"
1276
+ "残留锁核删指引见 error"))
1112
1277
  out["source_face_delta"] = face_delta(before, source_face_hashes(root))
1113
1278
  return out
1114
- out["merge_commit"] = _git_ok(G, root, "rev-parse", "HEAD").strip()
1115
1279
  out["merged"] = branch
1116
1280
  after = source_face_hashes(root)
1117
1281
  dlt = face_delta(before, after)
@@ -1270,13 +1434,18 @@ def run_cycle(cg=None, *, root: str = None, batch: str = None,
1270
1434
  phases["materialize"] = {k: m.get(k) for k in
1271
1435
  ("ok", "face_stable", "busy", "branch", "shadow",
1272
1436
  "git_dir", "baseline", "source_face_delta",
1273
- "reason", "error")}
1437
+ "reason", "error", "timeout")}
1274
1438
  if not m.get("ok"):
1275
- # 物化**未完成**(取不到根级锁 / 影子未能挂上)——与「门拦」区分记账:
1276
- # 门拦是「按设计不动手」,这里是「想动手却没做成」,用户要能一眼分开。
1439
+ # 物化**未完成**(取不到根级锁 / 影子未能挂上 / git 超时)——与
1440
+ # 「门拦」区分记账:门拦是「按设计不动手」,这里是「想动手却没做成」,
1441
+ # 用户要能一眼分开;超时再单列一档文案(issue #63:物化超时 ≠
1442
+ # 物化失败——前者是操作没完成、后者是 git 的裁决)。
1277
1443
  mat_fail = ("物化未完成:根级锁被占(另一进程在周期内)"
1278
1444
  if m.get("busy")
1279
- else "物化未完成:%s" % (m.get("error") or "未知原因"))
1445
+ else ("物化超时:%s" % (m.get("error") or "未知原因")
1446
+ if m.get("timeout")
1447
+ else "物化未完成:%s" % (m.get("error")
1448
+ or "未知原因")))
1280
1449
  if m.get("ok"):
1281
1450
  # **物化成功即迭代**(issue #60):`ok` 现在只说「影子挂上了没」;
1282
1451
  # 物化窗口内主库真源面的漂移另记 `face_stable`,**不阻断**迭代——
@@ -1292,7 +1461,7 @@ def run_cycle(cg=None, *, root: str = None, batch: str = None,
1292
1461
  cg=cg, timeout=timeout)
1293
1462
  phases["reconcile"] = {k: rc.get(k) for k in
1294
1463
  ("committed", "commit", "branch", "reason",
1295
- "busy")}
1464
+ "busy", "timeout", "error")}
1296
1465
  delta = rc.get("delta") or {}
1297
1466
  digest = {"n": len(delta), "ids": sorted(delta)[:20],
1298
1467
  "kinds": {k: sum(1 for v in delta.values() if v["kind"] == k)
@@ -1313,7 +1482,8 @@ def run_cycle(cg=None, *, root: str = None, batch: str = None,
1313
1482
  ("ok", "merged", "merge_commit",
1314
1483
  "round_commit", "replay_commit",
1315
1484
  "deferred", "conflict", "conflict_ids",
1316
- "error", "main_written", "busy")}
1485
+ "error", "main_written", "busy",
1486
+ "timeout")}
1317
1487
  phases["merge"]["replayed"] = (mg.get("replayed") or {}).get("written")
1318
1488
  rec["merged"] = bool(mg.get("ok")) and not mg.get("deferred")
1319
1489
  if mg.get("deferred"):