@hupan56/wlkj 2.0.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 (87) hide show
  1. package/bin/cli.js +213 -0
  2. package/package.json +11 -0
  3. package/templates/cli.js +198 -0
  4. package/templates/qoder/commands/wl-code.md +43 -0
  5. package/templates/qoder/commands/wl-commit.md +30 -0
  6. package/templates/qoder/commands/wl-init.md +80 -0
  7. package/templates/qoder/commands/wl-insight.md +51 -0
  8. package/templates/qoder/commands/wl-prd.md +199 -0
  9. package/templates/qoder/commands/wl-report.md +166 -0
  10. package/templates/qoder/commands/wl-search.md +52 -0
  11. package/templates/qoder/commands/wl-spec.md +18 -0
  12. package/templates/qoder/commands/wl-status.md +51 -0
  13. package/templates/qoder/commands/wl-task.md +71 -0
  14. package/templates/qoder/commands/wl-test.md +42 -0
  15. package/templates/qoder/config.toml +5 -0
  16. package/templates/qoder/config.yaml +141 -0
  17. package/templates/qoder/hooks/inject-workflow-state.py +117 -0
  18. package/templates/qoder/hooks/session-start.py +204 -0
  19. package/templates/qoder/rules/wl-pipeline.md +105 -0
  20. package/templates/qoder/scripts/add_session.py +245 -0
  21. package/templates/qoder/scripts/benchmark.py +209 -0
  22. package/templates/qoder/scripts/build_style_index.py +268 -0
  23. package/templates/qoder/scripts/code_index.py +41 -0
  24. package/templates/qoder/scripts/collect_prds.py +31 -0
  25. package/templates/qoder/scripts/common/__init__.py +0 -0
  26. package/templates/qoder/scripts/common/active_task.py +230 -0
  27. package/templates/qoder/scripts/common/atomicio.py +172 -0
  28. package/templates/qoder/scripts/common/developer.py +161 -0
  29. package/templates/qoder/scripts/common/eval_api.py +144 -0
  30. package/templates/qoder/scripts/common/feishu.py +278 -0
  31. package/templates/qoder/scripts/common/filelock.py +211 -0
  32. package/templates/qoder/scripts/common/identity.py +285 -0
  33. package/templates/qoder/scripts/common/mentions.py +134 -0
  34. package/templates/qoder/scripts/common/paths.py +311 -0
  35. package/templates/qoder/scripts/common/reqid.py +218 -0
  36. package/templates/qoder/scripts/common/search_engine.py +205 -0
  37. package/templates/qoder/scripts/common/task_utils.py +342 -0
  38. package/templates/qoder/scripts/common/terms.py +234 -0
  39. package/templates/qoder/scripts/common/utf8.py +38 -0
  40. package/templates/qoder/scripts/context_pack.py +196 -0
  41. package/templates/qoder/scripts/eval_prd.py +225 -0
  42. package/templates/qoder/scripts/export.py +487 -0
  43. package/templates/qoder/scripts/git_sync.py +1087 -0
  44. package/templates/qoder/scripts/handoff.py +22 -0
  45. package/templates/qoder/scripts/init_developer.py +76 -0
  46. package/templates/qoder/scripts/init_doctor.py +527 -0
  47. package/templates/qoder/scripts/install_qoderwork.py +339 -0
  48. package/templates/qoder/scripts/learn.py +67 -0
  49. package/templates/qoder/scripts/notify.py +5 -0
  50. package/templates/qoder/scripts/parse_prds.py +33 -0
  51. package/templates/qoder/scripts/report.py +281 -0
  52. package/templates/qoder/scripts/role.py +39 -0
  53. package/templates/qoder/scripts/run_weekly_update.bat +17 -0
  54. package/templates/qoder/scripts/run_weekly_update.sh +20 -0
  55. package/templates/qoder/scripts/search_index.py +352 -0
  56. package/templates/qoder/scripts/setup.py +453 -0
  57. package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
  58. package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
  59. package/templates/qoder/scripts/status.py +389 -0
  60. package/templates/qoder/scripts/syncgate.py +330 -0
  61. package/templates/qoder/scripts/task.py +954 -0
  62. package/templates/qoder/scripts/team.py +29 -0
  63. package/templates/qoder/scripts/team_sync.py +419 -0
  64. package/templates/qoder/scripts/workspace_init.py +102 -0
  65. package/templates/qoder/settings.json +53 -0
  66. package/templates/qoder/skills/design-review/SKILL.md +25 -0
  67. package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
  68. package/templates/qoder/skills/prd-review/SKILL.md +36 -0
  69. package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
  70. package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
  71. package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
  72. package/templates/qoder/skills/test-generator/SKILL.md +72 -0
  73. package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
  74. package/templates/qoder/skills/wl-init/SKILL.md +67 -0
  75. package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
  76. package/templates/qoder/skills/wl-report/SKILL.md +87 -0
  77. package/templates/qoder/skills/wl-search/SKILL.md +75 -0
  78. package/templates/qoder/skills/wl-status/SKILL.md +61 -0
  79. package/templates/qoder/skills/wl-task/SKILL.md +58 -0
  80. package/templates/qoder/templates/prd-full-template.md +103 -0
  81. package/templates/qoder/templates/prd-quick-template.md +69 -0
  82. package/templates/qoder/templates/prototype-app.html +344 -0
  83. package/templates/qoder/templates/prototype-web.html +310 -0
  84. package/templates/root/AGENTS.md +182 -0
  85. package/templates/root/README-pipeline.md +56 -0
  86. package/templates/root/ROLES.md +85 -0
  87. package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
@@ -0,0 +1,278 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ feishu.py - 飞书机器人通知 (阶段 D)
4
+
5
+ 通过群机器人 webhook 推送消息卡片, 失败不阻塞主流程。
6
+
7
+ 配置 (.qoder/config.yaml):
8
+ feishu:
9
+ enabled: true
10
+ webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
11
+ secret: "签名密钥(可选)"
12
+ events: [task_finished, prd_published, eval_rejected, deadline_warning]
13
+
14
+ 环境变量覆盖 (优先于 config):
15
+ FEISHU_WEBHOOK_URL, FEISHU_SECRET
16
+
17
+ 核心 API:
18
+ send_card(title, content, event_type, theme) -> bool
19
+ notify_task_finished(task_name, assignee, hours)
20
+ notify_prd_published(req_id, title, eval_pct)
21
+ notify_eval_rejected(req_id, title, issues)
22
+ notify_deadline(task_name, days_left, assignee)
23
+ """
24
+
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+ from datetime import datetime
32
+ from typing import Optional, List
33
+
34
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
35
+ if _THIS_DIR not in sys.path:
36
+ sys.path.insert(0, _THIS_DIR)
37
+
38
+ __all__ = [
39
+ "send_card",
40
+ "send_text",
41
+ "notify_task_finished",
42
+ "notify_prd_published",
43
+ "notify_eval_rejected",
44
+ "notify_deadline",
45
+ "is_enabled",
46
+ "get_config",
47
+ ]
48
+
49
+ # 主题色映射 (飞书 interactive card template)
50
+ THEME_COLORS = {
51
+ 'blue': 'blue',
52
+ 'green': 'turquoise',
53
+ 'orange': 'wathet',
54
+ 'red': 'red',
55
+ 'default': 'grey',
56
+ }
57
+
58
+
59
+ def _load_config():
60
+ """加载飞书配置 (config.yaml + 环境变量)。"""
61
+ # 读 config.yaml 的 feishu 段
62
+ config = {}
63
+ try:
64
+ import yaml
65
+ from .paths import get_repo_root
66
+ cfg_path = os.path.join(str(get_repo_root()), '.qoder', 'config.yaml')
67
+ with open(cfg_path, encoding='utf-8') as f:
68
+ full = yaml.safe_load(f) or {}
69
+ config = full.get('feishu') or {}
70
+ except Exception:
71
+ # PyYAML 不可用, 用简单解析
72
+ try:
73
+ from .paths import get_repo_root
74
+ cfg_path = os.path.join(str(get_repo_root()), '.qoder', 'config.yaml')
75
+ in_feishu = False
76
+ with open(cfg_path, encoding='utf-8') as f:
77
+ for line in f:
78
+ if line.startswith('feishu:'):
79
+ in_feishu = True
80
+ continue
81
+ if in_feishu:
82
+ if line and not line[0].isspace() and line.strip():
83
+ break # 离开 feishu 段
84
+ stripped = line.strip()
85
+ if stripped.startswith('enabled:'):
86
+ config['enabled'] = 'true' in stripped.lower()
87
+ elif stripped.startswith('webhook_url:'):
88
+ val = stripped.split(':', 1)[1].strip().strip('"').strip("'")
89
+ config['webhook_url'] = val
90
+ elif stripped.startswith('secret:'):
91
+ val = stripped.split(':', 1)[1].strip().strip('"').strip("'")
92
+ config['secret'] = val
93
+ except Exception:
94
+ pass
95
+
96
+ # 环境变量覆盖
97
+ env_url = os.environ.get('FEISHU_WEBHOOK_URL')
98
+ if env_url:
99
+ config['webhook_url'] = env_url
100
+ env_secret = os.environ.get('FEISHU_SECRET')
101
+ if env_secret:
102
+ config['secret'] = env_secret
103
+
104
+ return config
105
+
106
+
107
+ def get_config():
108
+ """公开的配置获取 (供调试/检查用)。"""
109
+ return _load_config()
110
+
111
+
112
+ def is_enabled(event_type: Optional[str] = None) -> bool:
113
+ """飞书推送是否启用 (含事件过滤)。
114
+
115
+ Args:
116
+ event_type: 若指定, 还检查该事件是否在 events 白名单里。
117
+ """
118
+ cfg = _load_config()
119
+ if not cfg.get('enabled'):
120
+ return False
121
+ if not cfg.get('webhook_url'):
122
+ return False
123
+ if event_type:
124
+ events = cfg.get('events')
125
+ # events 为空 = 全推 (向后兼容)
126
+ if events and event_type not in events:
127
+ return False
128
+ return True
129
+
130
+
131
+ def _gen_sign(secret: str, timestamp: int) -> str:
132
+ """飞书签名校验: HMAC-SHA256(timestamp + "\n" + secret, "")."""
133
+ string_to_sign = f'{timestamp}\n{secret}'
134
+ hmac_code = hmac.new(string_to_sign.encode('utf-8'), digestmod=hashlib.sha256).digest()
135
+ import base64
136
+ return base64.b64encode(hmac_code).decode('utf-8')
137
+
138
+
139
+ def _post_webhook(payload: dict) -> bool:
140
+ """POST 到飞书 webhook。用标准库 urllib (无第三方依赖)。
141
+
142
+ Returns:
143
+ True 若 HTTP 200 且飞书返回 code=0 (成功)。
144
+ """
145
+ cfg = _load_config()
146
+ url = cfg.get('webhook_url')
147
+ if not url:
148
+ return False
149
+
150
+ # 签名校验
151
+ secret = cfg.get('secret')
152
+ if secret:
153
+ timestamp = int(time.time())
154
+ payload['timestamp'] = str(timestamp)
155
+ payload['sign'] = _gen_sign(secret, timestamp)
156
+
157
+ try:
158
+ import urllib.request
159
+ data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
160
+ req = urllib.request.Request(
161
+ url, data=data,
162
+ headers={'Content-Type': 'application/json'},
163
+ method='POST',
164
+ )
165
+ with urllib.request.urlopen(req, timeout=10) as resp:
166
+ body = resp.read().decode('utf-8')
167
+ result = json.loads(body)
168
+ # 飞书成功返回 {"code": 0, "msg": "success"}
169
+ return result.get('code') == 0 or result.get('StatusCode') == 0
170
+ except Exception as e:
171
+ # 失败不阻塞主流程, 仅记日志
172
+ sys.stderr.write(f'[feishu] 推送失败 (不阻塞): {e}\n')
173
+ return False
174
+
175
+
176
+ def send_text(text: str) -> bool:
177
+ """发纯文本消息。"""
178
+ if not is_enabled():
179
+ return False
180
+ return _post_webhook({'msg_type': 'text', 'content': {'text': text}})
181
+
182
+
183
+ def send_card(
184
+ title: str,
185
+ content_lines: List[str],
186
+ event_type: Optional[str] = None,
187
+ theme: Optional[str] = None,
188
+ ) -> bool:
189
+ """发交互式卡片消息。
190
+
191
+ Args:
192
+ title: 卡片标题。
193
+ content_lines: 正文行列表 (每行一个 markdown 段落)。
194
+ event_type: 事件类型 (用于过滤 + 选主题色)。
195
+ theme: 手动指定主题色 (覆盖 event_type 映射)。
196
+ """
197
+ if not is_enabled(event_type):
198
+ return False
199
+
200
+ cfg = _load_config()
201
+ # 主题色: 优先手动指定, 否则从 config.theme[event_type] 取
202
+ if not theme:
203
+ themes = cfg.get('theme') or {}
204
+ theme = themes.get(event_type, 'default')
205
+ color = THEME_COLORS.get(theme, 'grey')
206
+
207
+ # 构建交互式卡片 (飞书 interactive card v2)
208
+ elements = []
209
+ for line in content_lines:
210
+ elements.append({
211
+ 'tag': 'div',
212
+ 'text': {'tag': 'lark_md', 'content': line},
213
+ })
214
+
215
+ # 模板色 + 标题
216
+ template = color if color != 'grey' else 'blue'
217
+ payload = {
218
+ 'msg_type': 'interactive',
219
+ 'card': {
220
+ 'config': {'wide_screen_mode': True},
221
+ 'header': {
222
+ 'title': {'tag': 'plain_text', 'content': title},
223
+ 'template': template,
224
+ },
225
+ 'elements': elements,
226
+ },
227
+ }
228
+ return _post_webhook(payload)
229
+
230
+
231
+ # ============================================================
232
+ # 事件专用通知 (D2 触发点调用)
233
+ # ============================================================
234
+
235
+ def notify_task_finished(task_name: str, assignee: str, hours: Optional[float] = None) -> bool:
236
+ """任务完成通知。"""
237
+ lines = [f'**任务完成** ✅', f'任务: {task_name}', f'负责人: {assignee}']
238
+ if hours is not None:
239
+ lines.append(f'用时: {hours:.1f} 小时')
240
+ lines.append(f'时间: {datetime.now().strftime("%Y-%m-%d %H:%M")}')
241
+ return send_card(f'任务完成: {task_name}', lines, event_type='task_finished')
242
+
243
+
244
+ def notify_prd_published(req_id: str, title: str, eval_pct: Optional[float] = None) -> bool:
245
+ """PRD 发布通知。"""
246
+ lines = [f'**PRD 已发布** 📄', f'编号: {req_id}', f'标题: {title}']
247
+ if eval_pct is not None:
248
+ lines.append(f'EVA 评分: {eval_pct:.0f}%')
249
+ lines.append(f'时间: {datetime.now().strftime("%Y-%m-%d %H:%M")}')
250
+ return send_card(f'PRD 发布: {req_id}', lines, event_type='prd_published')
251
+
252
+
253
+ def notify_eval_rejected(req_id: str, title: str, issues: List[str]) -> bool:
254
+ """EVA 门禁拒绝通知。"""
255
+ lines = [f'**PRD 未过质量门禁** ⛔', f'编号: {req_id}', f'标题: {title}', '']
256
+ lines.append('**问题:**')
257
+ for issue in issues[:5]:
258
+ lines.append(f'- {issue}')
259
+ if len(issues) > 5:
260
+ lines.append(f'- ... 还有 {len(issues) - 5} 个')
261
+ lines.append('')
262
+ lines.append('修复后重新提交, 或 admin 用 --skip-eval 跳过')
263
+ return send_card(f'⚠️ 质量门禁拦截: {req_id}', lines, event_type='eval_rejected')
264
+
265
+
266
+ def notify_deadline(task_name: str, days_left: int, assignee: str, title: str = '') -> bool:
267
+ """截止日期临近通知。"""
268
+ if days_left < 0:
269
+ lines = [f'**任务已逾期** 🔴', f'任务: {task_name}', f'负责人: {assignee}',
270
+ f'逾期 {-days_left} 天']
271
+ return send_card(f'逾期: {task_name}', lines, event_type='deadline_warning')
272
+ else:
273
+ urgency = '明天到期' if days_left <= 1 else f'{days_left} 天后到期'
274
+ lines = [f'**截止日期提醒** ⏰', f'任务: {task_name}', f'负责人: {assignee}',
275
+ f'状态: {urgency}']
276
+ if title:
277
+ lines.append(f'标题: {title}')
278
+ return send_card(f'截止提醒: {task_name}', lines, event_type='deadline_warning')
@@ -0,0 +1,211 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ filelock.py - 跨平台原子文件锁
4
+
5
+ 用 os.open(O_CREAT|O_EXCL) 实现真正的互斥(原子系统调用,无 TOCTOU 竞态)。
6
+ Windows / macOS / Linux 均无需管理员权限。
7
+
8
+ 用法:
9
+ from common.filelock import FileLock
10
+ with FileLock('.qoder/.runtime/team-sync.lock', timeout=30):
11
+ # 临界区:同一时刻只有一个进程能进入
12
+ do_critical_work()
13
+
14
+ 设计:
15
+ - acquire: O_CREAT|O_EXCL 原子创建。失败则锁被持有,等待重试。
16
+ - stale 检测: 读锁文件里的 PID,若进程已不存在则视为 stale 可抢。
17
+ - timeout: 总等待秒数;超时抛 LockTimeoutError。
18
+ - release: 持有者才能释放(记录 holding_pid 校验)。
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ import time
24
+ import errno
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+ from typing import Optional, Union
28
+
29
+ __all__ = ["FileLock", "LockTimeoutError", "LockBusyError"]
30
+
31
+
32
+ class LockTimeoutError(Exception):
33
+ """获取锁超过 timeout 秒。"""
34
+
35
+
36
+ class LockBusyError(Exception):
37
+ """锁被他人持有且 timeout=0(非阻塞模式立即失败)。"""
38
+
39
+
40
+ def _pid_alive(pid: int) -> bool:
41
+ """判断 PID 对应的进程是否还活着。跨平台尽力而为。"""
42
+ if pid <= 0:
43
+ return False
44
+ try:
45
+ if sys.platform == "win32":
46
+ # Windows: 用 OpenProcess 探测。0 表示句柄无效 → 进程已退出。
47
+ import ctypes
48
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
49
+ STILL_ACTIVE = 259
50
+ kernel32 = ctypes.windll.kernel32
51
+ h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
52
+ if not h:
53
+ return False
54
+ try:
55
+ exit_code = ctypes.c_ulong()
56
+ if not kernel32.GetExitCodeProcess(h, ctypes.byref(exit_code)):
57
+ return False
58
+ return exit_code.value == STILL_ACTIVE
59
+ finally:
60
+ kernel32.CloseHandle(h)
61
+ else:
62
+ # POSIX: kill(pid, 0) 不发信号,仅探测。EAGAIN 有时也表示存在。
63
+ try:
64
+ os.kill(pid, 0)
65
+ return True
66
+ except (OSError, ProcessLookupError):
67
+ return False
68
+ except PermissionError:
69
+ # 进程存在但属于他人
70
+ return True
71
+ except Exception:
72
+ # 任何探测失败都保守认为还活着(不抢他人锁)
73
+ return True
74
+
75
+
76
+ class FileLock:
77
+ """原子文件锁(基于 O_CREAT|O_EXCL)。
78
+
79
+ Args:
80
+ path: 锁文件路径。
81
+ timeout: 获取锁的总等待秒数。0 = 非阻塞,立即失败。负数 = 永久等待。
82
+ stale_seconds: 锁文件持有超过此秒数,且持有进程已死,视为 stale 可抢。
83
+ poll_interval: 重试间隔秒数。
84
+
85
+ 用法:
86
+ with FileLock('x.lock', timeout=30):
87
+ ...
88
+ # 或手动:
89
+ lock = FileLock('x.lock')
90
+ lock.acquire()
91
+ try:
92
+ ...
93
+ finally:
94
+ lock.release()
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ path: Union[str, Path],
100
+ timeout: float = 30.0,
101
+ stale_seconds: float = 7200.0,
102
+ poll_interval: float = 0.2,
103
+ ) -> None:
104
+ self.path = str(path)
105
+ self.timeout = timeout
106
+ self.stale_seconds = stale_seconds
107
+ self.poll_interval = poll_interval
108
+ self._holding = False # 本实例是否持有锁
109
+
110
+ def acquire(self) -> "FileLock":
111
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
112
+ start = time.monotonic()
113
+ while True:
114
+ # 原子创建尝试
115
+ try:
116
+ fd = os.open(
117
+ self.path,
118
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
119
+ 0o644,
120
+ )
121
+ try:
122
+ host = (os.environ.get("COMPUTERNAME")
123
+ or (os.uname().nodename if hasattr(os, "uname") else "?"))
124
+ content = "acquired={}\npid={}\nhost={}\n".format(
125
+ datetime.now().isoformat(timespec="seconds"),
126
+ os.getpid(),
127
+ host,
128
+ )
129
+ os.write(fd, content.encode("utf-8"))
130
+ finally:
131
+ os.close(fd)
132
+ self._holding = True
133
+ return self
134
+ except OSError as e:
135
+ if e.errno not in (errno.EEXIST, errno.EACCES):
136
+ raise
137
+ # 锁已存在 —— 只在"看起来 stale"时才做昂贵的 PID 探测
138
+ # 正常等待: 仅 stat 看 age, 不读文件不探测进程
139
+ try:
140
+ age = time.time() - os.stat(self.path).st_mtime
141
+ except OSError:
142
+ age = 0
143
+ # age 超过 stale_seconds 才值得探测; 否则纯等待
144
+ looks_stale = age > self.stale_seconds
145
+ if looks_stale and self._is_stale():
146
+ self._force_release_stale()
147
+ continue # 重试创建
148
+ # 还活着的锁 —— 决定等待还是失败
149
+ if self.timeout == 0:
150
+ raise LockBusyError("锁被持有: {}".format(self.path))
151
+ if 0 <= self.timeout < (time.monotonic() - start):
152
+ raise LockTimeoutError(
153
+ "获取锁超时 ({}s): {}".format(self.timeout, self.path)
154
+ )
155
+ time.sleep(self.poll_interval)
156
+
157
+ def release(self) -> None:
158
+ if not self._holding:
159
+ return
160
+ try:
161
+ os.remove(self.path)
162
+ except OSError:
163
+ pass
164
+ finally:
165
+ self._holding = False
166
+
167
+ def _is_stale(self) -> bool:
168
+ """锁文件是否可被视为 stale(持有进程已死 或 超过 stale_seconds 且进程不存活)。"""
169
+ try:
170
+ st = os.stat(self.path)
171
+ except OSError:
172
+ return False # 文件没了,外层会重试创建
173
+ age = time.time() - st.st_mtime
174
+ pid = self._read_pid()
175
+ if pid is None:
176
+ # 读不出 PID;保守起见,超过 stale_seconds 才视为 stale
177
+ return age > self.stale_seconds
178
+ alive = _pid_alive(pid)
179
+ if not alive:
180
+ return True
181
+ # 进程活但锁放太久(可能僵死)—— 超阈值才抢
182
+ return age > self.stale_seconds
183
+
184
+ def _read_pid(self) -> Optional[int]:
185
+ """从锁文件解析 pid=N 标记(容忍它出现在行中任意位置)。"""
186
+ try:
187
+ with open(self.path, "r", encoding="utf-8", errors="replace") as f:
188
+ for line in f:
189
+ # 同时支持 "pid=123" 在行首或行中("2026-... pid=123")
190
+ for tok in line.replace("\t", " ").split():
191
+ if tok.startswith("pid="):
192
+ try:
193
+ return int(tok.split("=", 1)[1].strip())
194
+ except ValueError:
195
+ continue
196
+ except OSError:
197
+ return None
198
+ return None
199
+
200
+ def _force_release_stale(self) -> None:
201
+ try:
202
+ os.remove(self.path)
203
+ except OSError:
204
+ pass
205
+
206
+ # 上下文管理器协议
207
+ def __enter__(self) -> "FileLock":
208
+ return self.acquire()
209
+
210
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
211
+ self.release()