claude-code-session-sync 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,621 @@
1
+ """atomicio:原子寫 + fsync + rename + 讀回驗;FS 能力評估;O_EXCL lock;local-open 偵測。
2
+
3
+ 依據 DESIGN 附錄(H6 crash-safe matrix、OQ8 不可靠 FS)+ PLAN v0.8 §2.8 / 決定 #8:
4
+ - 寫入 = 同目錄 temp → 寫 bytes → fsync(檔) → os.replace → fsync(目錄) → **讀回比對**。
5
+ 讀回不符(自身寫壞 **或** 並發被別人蓋)一律 raise,**永不靜默**(決定 #8)。
6
+ - `assess_fs`:保守白名單——只有已知日誌式本地 FS 視為可靠;USB/FAT/網路碟 → best-effort+警告。
7
+ rvw+lock **無論可靠與否都照做**;assess 只決定「是否額外警告 / 不宣稱 crash-safe」。
8
+ - `FileLock`:O_EXCL lockfile(非 fcntl,跨 OS)。取不到 → raise(不靜默 proceed)。
9
+ stale **只偵測不自動奪取**(hub 是跨機共用,跨 host 不可假設對方已死);交 doctor/人工。
10
+ - `is_local_open`:Linux /proc/fd 掃描,僅作**額外保險**,非「ff 進 local」依據(C3)。
11
+ - 不覆蓋 local 既有 JSONL(C3):上層用 `keep_both_path` 改寫檔名落地,不重寫內文。
12
+ 純標準庫。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import re
20
+ import socket
21
+ import sys
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ # 本模組產生的 temp 檔名格式(供孤兒清理辨識):.<原名>.<host>.<pid>.<hex>.tmp
29
+ # 含 host(已 sanitize 成 [A-Za-z0-9_-])才能在**共用 hub** 上分辨「他機進行中的 temp」,
30
+ # 避免本機把別台正在寫的 temp 當孤兒刪掉(codex r7-4)。
31
+ _TEMP_RE = re.compile(r"^\.(?P<base>.+)\.(?P<host>[A-Za-z0-9_-]+)\.(?P<pid>\d+)\.[0-9a-f]{32}\.tmp$")
32
+
33
+
34
+ class AtomicWriteError(Exception):
35
+ """原子寫失敗(含讀回驗不符)。"""
36
+
37
+
38
+ class VerifyError(AtomicWriteError):
39
+ """讀回驗不符:自身寫壞或寫入後被並發覆蓋(決定 #8:偵測到即中止,不靜默)。"""
40
+
41
+
42
+ class LockError(Exception):
43
+ """無法取得鎖。"""
44
+
45
+
46
+ class LockHeld(LockError):
47
+ """鎖被(看似存活的)他者持有 → 不 proceed。"""
48
+
49
+
50
+ class StaleLock(LockError):
51
+ """鎖看似陳舊(同 host 且持有 PID 已死)→ 交人工/二階段,**不自動奪取**。"""
52
+
53
+
54
+ def _utc_now_iso() -> str:
55
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
56
+
57
+
58
+ # ── 原子寫 ────────────────────────────────────────────────────────────────
59
+
60
+ # Windows:os.open() 預設 **text mode**,os.write 會把 \n 譯成 \r\n、讀回 raw bytes 多出 \r
61
+ # → 寫出 bytes ≠ 讀回 bytes:rvw 必 VerifyError,且即便不驗也已靜默竄改檔案內容(每個 \n 加 \r)。
62
+ # 故所有「寫/讀檔案內容」的 fd 一律加 O_BINARY(POSIX 無此旗標=0,無害)。跨 OS bytes 一致是本工具地基。
63
+ _O_BINARY = getattr(os, "O_BINARY", 0)
64
+
65
+
66
+ # ── 長路徑(繞過 Windows MAX_PATH=260)— **僅 memory-merge staging opt-in** ─────
67
+ #
68
+ # Windows 預設把路徑限在 260 字元(MAX_PATH)。memory-merge 暫存路徑
69
+ # `<cache>/claude-session-sync/merge/<pk>/<key>/<label>__<file>` 的 <pk>/<key> 各可達 ~200 字元
70
+ # (percent-encode 後)、兩段巢狀 → 由設計就常 >600 → os.mkdir/os.open 直接 OSError(memory-merge
71
+ # 現況回 status='error')。解法=把路徑轉成 `\\?\` 擴充長度形式(繞過 260,不依賴 LongPathsEnabled 登錄檔)。
72
+ #
73
+ # **範圍刻意限縮在 memory-merge staging**(各 atomic_* 的 `long_path=True`+merge 直接呼叫 os_path):其餘
74
+ # 寫入(session/tombstone/state/coverage/keep-both/index/lock)一律 `long_path=False` 預設=**260-bound、
75
+ # 等同改動前**。原因(codex longpath-r1 F1/F2):atomicio 的**讀取/枚舉端**(scan._session_files、
76
+ # tombstone.read_coverage/read_tombstones、canonical.load_bytes…)仍是 plain `Path`(260-bound);若讓非
77
+ # staging 寫入也能過 260,會造出「寫得進、下次讀不回」的**衍生路徑**不對稱——keep-both 加 `.synced-…`
78
+ # 尾綴後 >260 → 寫成功但下次 scan 枚舉略過 → 每輪重造隱形 keep-both(F1);`_coverage.json` >260 →
79
+ # bootstrap 報成功但 read_coverage 讀不到 → 專案被當未初始化(F2)。故非 staging 一律 260-bound、失敗即
80
+ # OSError(與改動前一致、fail-closed、可見),**不**靜默造出讀不回的檔。深 cwd 非 staging 檔的完整 >260
81
+ # 支援=需連讀取/枚舉層一起長路徑化(大範圍動 scan/apply/tombstone),留待需要時(見 HANDOFF 有界殘留)。
82
+ #
83
+ # `\\?\` 語意雷(故轉換前必先 abspath:絕對化+normpath):① 須絕對路徑;② 只認反斜線(停用 `/` 轉譯);
84
+ # ③ 不做 `.`/`..` 正規化(殘留即成字面元件);④ UNC 須寫成 `\\?\UNC\server\share\…`。只在 os.* 邊界套用;
85
+ # 模組內部 Path/字串保持未加前綴(例外訊息/state 不外洩 `\\?\`)。`os_path` 只改**長度**、不改跟隨語意
86
+ # (symlink/reparse 跟隨與否仍由 O_NOFOLLOW 與上游 lstat 守衛決定)。
87
+
88
+ def _win_longpath(abs_win_path: str) -> str:
89
+ r"""已 abspath 的 Windows 路徑 → `\\?\` 擴充長度形式(純字串轉換,可跨平台單元測試)。"""
90
+ s = abs_win_path
91
+ if s.startswith("\\\\?\\") or s.startswith("\\\\.\\"):
92
+ return s # 已是擴充長度/裝置命名空間 → 不重複前綴
93
+ if s.startswith("\\\\"): # UNC:\\server\share\… → \\?\UNC\server\share\…
94
+ return "\\\\?\\UNC\\" + s[2:]
95
+ return "\\\\?\\" + s # 磁碟:C:\… → \\?\C:\…
96
+
97
+
98
+ def os_path(path: str | os.PathLike) -> str:
99
+ r"""把路徑轉成可直接餵給 `os.*` 系統呼叫的字串。Windows 上套 `\\?\` 擴充長度前綴(繞過 260 字元
100
+ MAX_PATH);POSIX 原樣回傳(`os.fspath`,零行為變動)。只在系統呼叫邊界呼叫、結果不儲存/不顯示。"""
101
+ s = os.fspath(path)
102
+ if os.name != "nt":
103
+ return s
104
+ if s.startswith("\\\\?\\") or s.startswith("\\\\.\\"):
105
+ return s # 已加前綴 → 不重複(abspath 可能毀損既有 \\?\)
106
+ return _win_longpath(os.path.abspath(s)) # abspath=絕對化+normpath(收斂 `.`/`..`/混用斜線)
107
+
108
+
109
+ def read_bytes(path: str | os.PathLike) -> bytes:
110
+ """讀整檔 bytes(長路徑安全;跟隨 symlink,同 `Path.read_bytes()`)。"""
111
+ with open(os_path(path), "rb") as f:
112
+ return f.read()
113
+
114
+
115
+ def read_text(path: str | os.PathLike, *, encoding: str = "utf-8") -> str:
116
+ """讀整檔文字(長路徑安全;沿用預設 universal-newline,對 JSON/文字中繼無害,同 `Path.read_text()`)。"""
117
+ with open(os_path(path), "r", encoding=encoding) as f:
118
+ return f.read()
119
+
120
+
121
+ def _mkdirs(d: str | os.PathLike, *, long_path: bool = False) -> None:
122
+ r"""`os.makedirs(exist_ok=True)`(等價原 `Path.mkdir(parents=True, exist_ok=True)`)。
123
+ `long_path=True`(僅 memory-merge staging)→ 走 os_path 的 `\\?\` 繞過 MAX_PATH;否則原樣(260-bound)。"""
124
+ os.makedirs(os_path(d) if long_path else os.fspath(d), exist_ok=True)
125
+
126
+
127
+ def _write_all(fd: int, data: bytes) -> None:
128
+ """os.write 可能短寫;迴圈寫到完。"""
129
+ mv = memoryview(data)
130
+ while mv:
131
+ n = os.write(fd, mv)
132
+ mv = mv[n:]
133
+
134
+
135
+ def _fsync_dir(d: Path) -> None:
136
+ """fsync 目錄項(讓 rename 落地)。某些平台/FS(Windows、部分網路碟)不支援 → 安靜略過。"""
137
+ try:
138
+ dfd = os.open(str(d), os.O_RDONLY) # 260-bound(best-effort:Windows/網路碟本就失敗→略過;staging 深夾在 Linux 原生可開)
139
+ except OSError:
140
+ return
141
+ try:
142
+ os.fsync(dfd)
143
+ except OSError:
144
+ pass # 不支援 dir fsync(best-effort)
145
+ finally:
146
+ os.close(dfd)
147
+
148
+
149
+ def _temp_path(target: Path) -> Path:
150
+ """同目錄、隱藏、含 host+pid+亂數的唯一 temp 名(同 FS → rename 原子;host 供跨機孤兒辨識)。"""
151
+ return target.with_name(f".{target.name}.{_host_tag()}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
152
+
153
+
154
+ def atomic_write_bytes(path: str | os.PathLike, data: bytes, *,
155
+ do_fsync: bool = True, verify: bool = True, long_path: bool = False) -> None:
156
+ """原子寫 bytes:同目錄 temp → fsync → os.replace → fsync(dir) → 讀回比對。
157
+
158
+ 讀回不符 → VerifyError(自身寫壞或並發覆蓋;決定 #8 不靜默)。失敗會清掉自己的 temp。
159
+ 註:本函式只寫**目的端**(source 不動),故即便崩潰丟失剛寫入的檔,重跑同步即可復原;
160
+ 跨斷電的耐久性僅在可靠 FS 保證(見 assess_fs),不可靠 FS 為 best-effort(DESIGN H6)。
161
+ """
162
+ target = Path(path)
163
+ _wp = os_path if long_path else os.fspath # long_path(僅 memory-merge staging)→ \\?\ 繞過 260;否則 260-bound(改動前行為)
164
+ _mkdirs(target.parent, long_path=long_path)
165
+ tmp = _temp_path(target)
166
+ created = False # 只有 os.open 成功、且尚未 rename 時,temp 才是「我們建的、待清理」
167
+ try:
168
+ fd = os.open(_wp(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_BINARY, 0o600)
169
+ created = True
170
+ try:
171
+ _write_all(fd, data)
172
+ if do_fsync:
173
+ os.fsync(fd)
174
+ finally:
175
+ os.close(fd)
176
+ os.replace(_wp(tmp), _wp(target)) # POSIX 同目錄 rename 原子;Windows 可覆蓋
177
+ created = False # 已 rename,temp 不再存在 → 別在 finally 誤刪到真檔
178
+ if do_fsync:
179
+ _fsync_dir(target.parent)
180
+ if verify:
181
+ got = read_bytes(target) if long_path else target.read_bytes()
182
+ if got != data:
183
+ raise VerifyError(
184
+ f"讀回驗不符:{target}(自身寫壞或寫入後被並發覆蓋;len got={len(got)} want={len(data)})"
185
+ )
186
+ finally:
187
+ if created:
188
+ with contextlib.suppress(OSError):
189
+ os.unlink(_wp(tmp))
190
+
191
+
192
+ def atomic_write_text(path: str | os.PathLike, text: str, *,
193
+ do_fsync: bool = True, verify: bool = True, long_path: bool = False) -> None:
194
+ atomic_write_bytes(path, text.encode("utf-8"), do_fsync=do_fsync, verify=verify, long_path=long_path)
195
+
196
+
197
+ def atomic_copy(src: str | os.PathLike, dst: str | os.PathLike, *,
198
+ do_fsync: bool = True, verify: bool = True) -> None:
199
+ """把 src 的 bytes 原子寫到 dst(讀回驗,**可覆蓋**)。供寫 hub(允許覆蓋)用。"""
200
+ atomic_write_bytes(dst, Path(src).read_bytes(), do_fsync=do_fsync, verify=verify)
201
+
202
+
203
+ def atomic_create_bytes(path: str | os.PathLike, data: bytes, *,
204
+ do_fsync: bool = True, verify: bool = True, long_path: bool = False) -> None:
205
+ """**只建不覆蓋**:以 O_CREAT|O_EXCL 直接開最終路徑寫入;已存在 → FileExistsError(呼叫端決定 keep-both)。
206
+
207
+ 供 local 寫入(C3:絕不覆蓋既有 local JSONL)。不走 temp+rename——rename 會覆蓋,破壞 no-clobber;
208
+ O_EXCL 跨所有 FS(含 exFAT/網路碟)都成立。崩潰中途 → 部分**新**檔(無既有資料損失);失敗清掉自建檔。
209
+ """
210
+ target = Path(path)
211
+ _wp = os_path if long_path else os.fspath # long_path(僅 memory-merge staging)→ \\?\ 繞過 260;否則 260-bound
212
+ _mkdirs(target.parent, long_path=long_path)
213
+ try:
214
+ fd = os.open(_wp(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_BINARY, 0o600)
215
+ except FileExistsError:
216
+ raise # 不覆蓋;不刪別人的檔
217
+ ok = False
218
+ try:
219
+ try:
220
+ _write_all(fd, data)
221
+ if do_fsync:
222
+ os.fsync(fd)
223
+ finally:
224
+ os.close(fd)
225
+ if do_fsync:
226
+ _fsync_dir(target.parent)
227
+ if verify and (read_bytes(target) if long_path else target.read_bytes()) != data:
228
+ raise VerifyError(f"讀回驗不符(新建):{target}")
229
+ ok = True
230
+ finally:
231
+ if not ok: # 寫入/驗證階段失敗 → 清掉自己建的部分檔
232
+ with contextlib.suppress(OSError):
233
+ os.unlink(_wp(target))
234
+
235
+
236
+ # ── FS 能力評估(H6 / OQ8)────────────────────────────────────────────────
237
+
238
+ # 只有「已知日誌式本地 FS」視為可靠(crash-safe + fsync 有意義 + 單機獨占)。
239
+ # 其餘(FAT 家族/USB/網路碟/未知)一律保守視為不可靠 → best-effort + 警告。
240
+ RELIABLE_FS = frozenset({
241
+ "ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "jfs", "reiserfs",
242
+ "f2fs", "apfs", "hfs", "hfsplus", "ufs", "bcachefs",
243
+ })
244
+ # 明確不可靠(列出僅供訊息更清楚;判定仍以「不在白名單即不可靠」為準)。
245
+ UNRELIABLE_FS = frozenset({
246
+ "vfat", "fat", "fat32", "msdos", "exfat", "ntfs", "fuseblk",
247
+ "nfs", "nfs4", "cifs", "smbfs", "smb2", "smb3", "9p", "afpfs",
248
+ "fuse.sshfs", "fuse.gvfsd-fuse", "tmpfs",
249
+ })
250
+
251
+
252
+ def classify_fstype(fstype: str | None) -> bool:
253
+ """純函式:fstype → 是否可靠(crash-safe)。未知/不在白名單 → 不可靠(保守)。"""
254
+ if not fstype:
255
+ return False
256
+ return fstype.lower() in RELIABLE_FS
257
+
258
+
259
+ def _unescape_mount(field: str) -> str:
260
+ """/proc/mounts 把 space/tab/nl/backslash 等寫成八進位 \\040 \\011 \\012 \\134。
261
+ **只**還原這些八進位序列;不可用 codecs `unicode_escape`——它會把多位元組 UTF-8(如 CJK
262
+ 掛載點 /mnt/共享)當 latin-1 拆解而毀損,導致最長前綴比對失敗、誤判為可靠 FS(codex r7-7)。"""
263
+ return re.sub(r"\\(\d{3})", lambda m: chr(int(m.group(1), 8)), field)
264
+
265
+
266
+ def detect_fstype(path: str | os.PathLike) -> str | None:
267
+ """偵測 path 所在掛載的 FS 類型。Linux 走 /proc/mounts(取最長匹配掛載點);
268
+ 其他平台無可靠跨 OS 法 → None(呼叫端保守視為不可靠)。"""
269
+ if not sys.platform.startswith("linux"):
270
+ return None
271
+ try:
272
+ real = os.path.realpath(str(path))
273
+ except OSError:
274
+ return None
275
+ try:
276
+ raw = Path("/proc/mounts").read_text(encoding="utf-8", errors="replace")
277
+ except OSError:
278
+ return None
279
+ best_mount = ""
280
+ best_type: str | None = None
281
+ for line in raw.splitlines():
282
+ parts = line.split()
283
+ if len(parts) < 3:
284
+ continue
285
+ mount = _unescape_mount(parts[1]) # 只還原八進位轉義,不毀損非 ASCII(見 _unescape_mount)
286
+ fstype = parts[2]
287
+ if (real == mount or real.startswith(mount.rstrip("/") + "/")) and len(mount) >= len(best_mount):
288
+ best_mount = mount
289
+ best_type = fstype
290
+ return best_type
291
+
292
+
293
+ @dataclass
294
+ class FsAssessment:
295
+ path: str
296
+ fstype: str | None
297
+ reliable: bool
298
+ can_write: bool
299
+ reason: str
300
+
301
+
302
+ def assess_fs(dir_path: str | os.PathLike) -> FsAssessment:
303
+ """評估某目錄:FS 類型、是否可靠(crash-safe)、是否可寫。對 hub/local/state/quarantine 各評一次。"""
304
+ d = Path(dir_path)
305
+ fstype = detect_fstype(d)
306
+ reliable = classify_fstype(fstype)
307
+ can_write = False
308
+ write_note = ""
309
+ if not d.is_dir():
310
+ # **不自動建立**——若這是消失的掛載點,建立它會在裸 mountpoint 上製造假目錄、後續寫入落錯 FS(codex r15-2)。
311
+ write_note = ",目錄不存在(不自動建立)"
312
+ else:
313
+ probe = d / f".csync-probe.{os.getpid()}.{uuid.uuid4().hex}.tmp"
314
+ try:
315
+ probe.write_bytes(b"probe")
316
+ can_write = True
317
+ except OSError as e:
318
+ write_note = f",無法寫入({e.__class__.__name__})"
319
+ finally:
320
+ with contextlib.suppress(OSError):
321
+ probe.unlink()
322
+ if fstype is None:
323
+ reason = f"未知 FS 類型(保守視為不可靠:保留 rvw+lock,不宣稱 crash-safe){write_note}"
324
+ elif reliable:
325
+ reason = f"已知日誌式本地 FS({fstype}){write_note}"
326
+ else:
327
+ reason = f"非日誌式/可移除/網路 FS({fstype})→ best-effort + 警告{write_note}"
328
+ return FsAssessment(str(d), fstype, reliable, can_write, reason)
329
+
330
+
331
+ # ── O_EXCL lock ───────────────────────────────────────────────────────────
332
+
333
+ @dataclass
334
+ class LockInfo:
335
+ pid: int | None
336
+ host: str | None
337
+ time: str | None
338
+ token: str | None = None
339
+ raw: str | None = None
340
+
341
+
342
+ def _local_host() -> str:
343
+ return socket.gethostname() or "unknown"
344
+
345
+
346
+ def _host_tag() -> str:
347
+ """sanitize 過的本機名(供 temp 命名 / 跨機孤兒比對;只含 [A-Za-z0-9_-])。"""
348
+ return re.sub(r"[^A-Za-z0-9_-]", "-", _local_host())[:32] or "host"
349
+
350
+
351
+ _DISP_CTRL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
352
+
353
+
354
+ def _disp(v: object) -> str:
355
+ """把**不可信的鎖 metadata**(host/pid/time/path,可能來自 malformed .lock)轉成可安全印出的字串:剔控制
356
+ 字元(含換行/CR/tab)+ 中和 lone surrogate(→ `?`)。否則含 surrogate 的鎖內容會令 strict-UTF-8 stdout 在印
357
+ doctor 報告/FileLock 例外訊息時拋 UnicodeEncodeError(破指令)。對稱 `merge._disp`。**只用於顯示**——
358
+ 比對用的原始值(如 break_locks 的 token)不經此淨化。"""
359
+ return _DISP_CTRL_RE.sub("", str(v)).encode("utf-8", "replace").decode("utf-8")
360
+
361
+
362
+ def _pid_alive(pid: int) -> bool:
363
+ """同 host 上的 PID 是否存活。無法判定一律當「存活」(保守,不奪鎖)。"""
364
+ if pid <= 0:
365
+ return True
366
+ if os.name == "nt":
367
+ # Windows:os.kill(pid, 0) **不是**存活探測,會以 TerminateProcess **殺掉**目標進程!
368
+ # 改用 ctypes OpenProcess+GetExitCodeProcess(唯讀查詢、不動目標)(codex r7-2 舊註「一律 True」已由此取代)。
369
+ return _pid_alive_windows(pid)
370
+ try:
371
+ os.kill(pid, 0)
372
+ except ProcessLookupError:
373
+ return False
374
+ except OverflowError:
375
+ return True # pid 超出 C pid_t 範圍(malformed 鎖/temp 名)→ 無法判定 → 保守(對稱 _pid_alive_windows 的 DWORD 守衛)
376
+ except PermissionError:
377
+ return True # 存在但他人擁有
378
+ except OSError:
379
+ return True # 無法判定 → 保守
380
+ return True
381
+
382
+
383
+ def _pid_alive_windows(pid: int) -> bool:
384
+ """Windows PID 存活探測(ctypes;**絕不用 os.kill**——Windows os.kill(pid,0) 會殺進程)。
385
+
386
+ A6 保守鐵則(安全方向=寧可回「存活」不奪鎖):只有「明確無此 PID」或「明確已終止」才回 False
387
+ (可判 stale);任何無法判定(DLL 載入失敗/權限不足 ACCESS_DENIED/查詢失敗/罕見 exit code
388
+ 259=STILL_ACTIVE 與存活無法區分)一律回 True。故 PID 重用只會令已死者被誤判「存活」→ 保守保留
389
+ (不誤奪鎖);break-lock 另在 unlink 前再驗一次(doctor.break_locks)擋列出→移除間的重取 race。"""
390
+ if not (0 < pid <= 0xFFFFFFFF):
391
+ return True # 超出 Windows PID(DWORD)合法範圍(來自 malformed 鎖/temp 檔名)→ 無法判定 → 保守存活
392
+ try:
393
+ import ctypes
394
+ from ctypes import wintypes
395
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
396
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
397
+ ERROR_INVALID_PARAMETER = 87 # OpenProcess 對「無此 PID」回此碼 → 已死
398
+ STILL_ACTIVE = 259 # GetExitCodeProcess:仍在跑
399
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
400
+ kernel32.OpenProcess.restype = wintypes.HANDLE # c_void_p:避免 64-bit handle 被截斷
401
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,) # 同理,handle 以 pointer-size 傳回
402
+ kernel32.CloseHandle.restype = wintypes.BOOL
403
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
404
+ if not handle:
405
+ # 明確「無此 PID」→ 已死;其餘(ACCESS_DENIED=5 等)=存在但無權查 → 保守當存活。
406
+ return ctypes.get_last_error() != ERROR_INVALID_PARAMETER
407
+ try:
408
+ code = wintypes.DWORD()
409
+ kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
410
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
411
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
412
+ return True # 查詢失敗 → 無法判定 → 保守當存活
413
+ return code.value == STILL_ACTIVE # 仍在跑=存活;其餘=已終止(已死)
414
+ finally:
415
+ kernel32.CloseHandle(handle)
416
+ except Exception: # noqa: BLE001 — 任何 ctypes 載入/呼叫失敗=無法判定 → 保守當存活(絕不誤奪鎖)
417
+ return True
418
+
419
+
420
+ class FileLock:
421
+ """`<resource>.lock` 的 O_EXCL 鎖。取不到 → raise(LockHeld/StaleLock),不靜默 proceed。
422
+
423
+ stale 只**偵測**(同 host 且 PID 已死)並 raise StaleLock,**不自動奪取**——hub 跨機共用,
424
+ 跨 host 無法判定對方死活,誤奪會互蓋(決定 #8 / PLAN §2.8)。
425
+ """
426
+
427
+ def __init__(self, resource_path: str | os.PathLike):
428
+ self.lock_path = Path(str(resource_path) + ".lock")
429
+ self._fd: int | None = None
430
+ self._token: str | None = None # 本次持有的唯一憑證(release 時憑此確認仍是自己再刪)
431
+
432
+ def _read_info(self) -> LockInfo:
433
+ try:
434
+ raw = self.lock_path.read_text(encoding="utf-8")
435
+ except (OSError, UnicodeDecodeError):
436
+ # 缺/不可讀/**非 UTF-8**(malformed 鎖,如 b"\xff")→ 無法解析 → fail-closed(不 crash break-lock/acquire)。
437
+ return LockInfo(None, None, None)
438
+ try:
439
+ d = json.loads(raw)
440
+ return LockInfo(d.get("pid"), d.get("host"), d.get("time"), d.get("token"), raw)
441
+ except Exception: # noqa: BLE001
442
+ return LockInfo(None, None, None, None, raw)
443
+
444
+ def _is_stale(self, info: LockInfo) -> bool:
445
+ # 只有「同 host 且 PID 明確已死」才算 stale;跨 host / 無法解析 → 不算(不奪)。
446
+ if info.host != _local_host():
447
+ return False
448
+ # `type() is int`(非 isinstance)——bool 是 int 子類,JSON `true` 會被 isinstance 當 int(值=1)→
449
+ # 在 Windows 被當 PID 1 探測 → 誤判 stale 刪掉 malformed 鎖。malformed pid 一律 fail-closed 不奪。
450
+ if type(info.pid) is not int:
451
+ return False
452
+ return not _pid_alive(info.pid)
453
+
454
+ def acquire(self) -> "FileLock":
455
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
456
+ try:
457
+ fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY | _O_BINARY, 0o600)
458
+ except FileExistsError:
459
+ info = self._read_info()
460
+ if self._is_stale(info):
461
+ raise StaleLock(
462
+ f"鎖看似陳舊(host={_disp(info.host)} pid={_disp(info.pid)} 已不存在):{_disp(self.lock_path)}。"
463
+ f"請確認無其他同步在跑後手動移除(doctor),不自動奪取。"
464
+ )
465
+ raise LockHeld(
466
+ f"鎖被持有中(host={_disp(info.host)} pid={_disp(info.pid)} time={_disp(info.time)}):{_disp(self.lock_path)}")
467
+ self._fd = fd
468
+ self._token = uuid.uuid4().hex
469
+ payload = json.dumps(
470
+ {"pid": os.getpid(), "host": _local_host(), "time": _utc_now_iso(), "token": self._token},
471
+ ensure_ascii=False,
472
+ ).encode("utf-8")
473
+ with contextlib.suppress(OSError):
474
+ os.write(fd, payload)
475
+ os.fsync(fd)
476
+ return self
477
+
478
+ def acquire_blocking(self, *, timeout_s: float = 5.0, poll_s: float = 0.05) -> "FileLock":
479
+ """LockHeld(他者存活持有)時輪詢重試到 timeout;逾時 raise LockError,不靜默 proceed。
480
+ StaleLock 不在此攔截 → 直接外拋(等待不會讓已死的持有者釋放,交 doctor/人工)。"""
481
+ deadline = time.monotonic() + max(0.0, timeout_s)
482
+ while True:
483
+ try:
484
+ return self.acquire()
485
+ except LockHeld:
486
+ if time.monotonic() >= deadline:
487
+ raise LockError(f"等待鎖逾時({timeout_s}s):{self.lock_path}")
488
+ time.sleep(poll_s)
489
+
490
+ def release(self) -> None:
491
+ if self._fd is None:
492
+ return
493
+ with contextlib.suppress(OSError):
494
+ os.close(self._fd)
495
+ self._fd = None
496
+ token, self._token = self._token, None
497
+ # 只在「目前 lockfile 仍是自己」時移除:憑 content token 比對(比 inode 在 exFAT/網路碟更可靠)。
498
+ # 否則(被人手動清掉後、別的 writer 已重取鎖)會誤刪他人的鎖、放第三者進來(codex r7-3)。
499
+ if token is not None and self._read_info().token == token:
500
+ with contextlib.suppress(OSError):
501
+ os.unlink(str(self.lock_path))
502
+
503
+ def __enter__(self) -> "FileLock":
504
+ return self.acquire()
505
+
506
+ def __exit__(self, *exc) -> None:
507
+ self.release()
508
+
509
+
510
+ # ── local-open 偵測(額外保險,非 ff 依據)────────────────────────────────
511
+
512
+ def is_local_open(path: str | os.PathLike) -> bool | None:
513
+ """該檔是否被某進程開啟。Linux 掃 /proc/*/fd;無法判定(非 Linux/無 /proc/權限不足)→ None。
514
+
515
+ 僅作**額外保險**:C3 的真正保護是「絕不覆蓋 local 既有 JSONL」,不靠此偵測。
516
+ """
517
+ if not sys.platform.startswith("linux"):
518
+ return None
519
+ proc = Path("/proc")
520
+ if not proc.exists():
521
+ return None
522
+ try:
523
+ target = os.path.realpath(str(path))
524
+ except OSError:
525
+ return None
526
+ saw_denied = False # 有看不到的進程(hidepid/他人持有)→ 不能斷言「沒開」,回 None
527
+ for pid_dir in proc.iterdir():
528
+ if not pid_dir.name.isdigit():
529
+ continue
530
+ fd_dir = pid_dir / "fd"
531
+ try:
532
+ entries = list(fd_dir.iterdir())
533
+ except PermissionError:
534
+ saw_denied = True
535
+ continue
536
+ except OSError:
537
+ continue # 進程已退
538
+ for fd in entries:
539
+ try:
540
+ if os.path.realpath(str(fd)) == target:
541
+ return True
542
+ except OSError:
543
+ continue
544
+ return None if saw_denied else False
545
+
546
+
547
+ # ── keep-both / 孤兒 temp 清理 ─────────────────────────────────────────────
548
+
549
+ _KEEP_BOTH_TRIES = 8 # 取不碰撞 keep-both 檔名的重試次數(O_EXCL race 極罕見)
550
+
551
+
552
+ def write_keep_both(target: str | os.PathLike, data: bytes, *,
553
+ machine: str | None = None, tries: int = _KEEP_BOTH_TRIES) -> Path:
554
+ """把 data 以 **O_EXCL 只建不覆蓋** 寫到 target 旁的不碰撞 sibling 檔名(C3:絕不覆蓋既有檔)。
555
+
556
+ 回實際寫入的新路徑。名字被搶(極罕見 race)→ 換一個重試;用罄 → AtomicWriteError。
557
+ 供 ff hub->local / copy 期間冒出同名 / 互動 union·keep-both 共用(單一來源)。"""
558
+ for _ in range(tries):
559
+ dest = keep_both_path(target, machine=machine)
560
+ try:
561
+ atomic_create_bytes(dest, data)
562
+ return dest
563
+ except FileExistsError:
564
+ continue
565
+ raise AtomicWriteError(f"無法取得不碰撞的 keep-both 檔名:{target}")
566
+
567
+
568
+ def keep_both_path(target: str | os.PathLike, *, machine: str | None = None) -> Path:
569
+ """為「不可覆蓋 local 既有 JSONL」產生不碰撞的 sibling 檔名(B6:複製+改檔名,不重寫內文)。
570
+
571
+ 新 stem 即 Claude resume 時的 session 身分(檔名為配對鍵)。回**尚不存在**的路徑。
572
+ """
573
+ t = Path(target)
574
+ host = re.sub(r"[^A-Za-z0-9_-]", "-", (machine or _local_host()))[:24]
575
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
576
+ base = f"{t.stem}.synced-{host}-{stamp}"
577
+ cand = t.with_name(base + t.suffix)
578
+ n = 1
579
+ while cand.exists():
580
+ cand = t.with_name(f"{base}.{n}{t.suffix}")
581
+ n += 1
582
+ return cand
583
+
584
+
585
+ def cleanup_orphan_temps(dir_path: str | os.PathLike, *, max_age_s: float = 3600.0) -> list[str]:
586
+ """清掉本模組遺留的孤兒 temp(.<名>.<host>.<pid>.<hex>.tmp)。回已刪清單。
587
+
588
+ 跨機共用 hub 安全規則(codex r7-4)——只在以下情況刪,避免誤刪他機進行中的 temp:
589
+ - **本機** host + PID 已死 → 孤兒,刪。
590
+ - **本機** host + PID 存活 + 新(≤max_age)→ 進行中,留。
591
+ - **本機** host + 很舊(>max_age)→ 視為孤兒(PID 可能已被回收再用),刪。
592
+ - **他機** host → 無法判存活;只在很舊(>max_age)才刪,否則一律留。
593
+ """
594
+ d = Path(dir_path)
595
+ removed: list[str] = []
596
+ if not d.exists():
597
+ return removed
598
+ local = _host_tag()
599
+ now = time.time()
600
+ for p in d.iterdir():
601
+ if not p.is_file():
602
+ continue
603
+ m = _TEMP_RE.match(p.name)
604
+ if not m:
605
+ continue
606
+ host = m.group("host")
607
+ pid = int(m.group("pid"))
608
+ try:
609
+ age = now - p.stat().st_mtime
610
+ except OSError:
611
+ continue
612
+ old = age > max_age_s
613
+ if host == local:
614
+ if _pid_alive(pid) and not old:
615
+ continue # 本機、存活、新 → 進行中
616
+ elif not old:
617
+ continue # 他機、無法判存活、且新 → 保守保留
618
+ with contextlib.suppress(OSError):
619
+ p.unlink()
620
+ removed.append(p.name)
621
+ return removed