@hupan56/wlkj 3.1.24 → 3.1.26

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.
@@ -1,428 +1,428 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- QODER Pipeline - 任务工具函数
5
-
6
- 提供:
7
- - resolve_task_dir: 解析任务目录 (支持名称/相对路径/绝对路径)
8
- - run_task_hooks: 运行任务生命周期钩子
9
- - load_task_json: 加载 task.json
10
- - write_task_json: 写入 task.json
11
-
12
- 参考: Trellis 的 common/task_utils.py 设计
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import json
18
- import os
19
- import subprocess
20
- import sys
21
- from pathlib import Path
22
-
23
- from foundation.core.paths import DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_tasks_dir
24
-
25
- # 原子写 (解决半写损坏)
26
- try:
27
- from foundation.io.atomicio import atomic_write_json
28
- except ImportError:
29
- def atomic_write_json(path, data, indent=2, ensure_ascii=False, sort_keys=True):
30
- import json as _j
31
- Path(path).write_text(_j.dumps(data, indent=indent, ensure_ascii=ensure_ascii,
32
- sort_keys=sort_keys) + "\n", encoding="utf-8")
33
-
34
- # 安全读 (区分缺失/损坏, 损坏备份 .corrupt) — 与 atomic_write_json 同源
35
- try:
36
- from foundation.io.atomicio import safe_read_json, AtomicIOError
37
- except ImportError:
38
- # 兜底: 退化回旧的裸读 (损坏静默返回 None, 不备份)
39
- def safe_read_json(path, default=None, required=False, backup_corrupt=True):
40
- if not os.path.isfile(path):
41
- return default
42
- try:
43
- return json.loads(Path(path).read_text(encoding="utf-8"))
44
- except (ValueError, json.JSONDecodeError, OSError, IOError):
45
- return default
46
-
47
- class AtomicIOError(Exception):
48
- """兜底类型 (仅当 atomicio 不可用时定义, 不会真正抛出)。"""
49
-
50
-
51
- # =============================================================================
52
- # 任务目录解析
53
- # =============================================================================
54
-
55
- def resolve_task_dir(task_input: str, repo_root: Path | None = None) -> Path:
56
- """解析任务目录路径。
57
-
58
- 支持三种输入:
59
- 1. 纯名称/全名: "my-task" / "06-10-my-task" -> workspace/tasks/<name>
60
- 2. 相对路径: "workspace/tasks/06-10-my-task" -> 完整路径
61
- 3. 绝对路径: 直接使用
62
-
63
- 安全变更 (零信任): 不再做模糊子串匹配 (旧版 "a" 可能匹配到他人的
64
- "06-10-alpha" 导致误操作)。现在要求精确名或唯一前缀。
65
-
66
- Args:
67
- task_input: 任务标识 (名称或路径)。
68
- repo_root: 项目根目录, 默认自动检测。
69
-
70
- Returns:
71
- 任务目录的绝对路径。
72
-
73
- Raises:
74
- FileNotFoundError: 找不到任务。
75
- ValueError: 名称歧义 (多个候选)。
76
- """
77
- if repo_root is None:
78
- repo_root = get_repo_root()
79
-
80
- input_path = Path(task_input)
81
-
82
- # 已经是绝对路径且存在
83
- if input_path.is_absolute() and input_path.is_dir():
84
- return input_path.resolve()
85
-
86
- # 相对路径
87
- full_from_root = (repo_root / task_input).resolve()
88
- if full_from_root.is_dir():
89
- return full_from_root
90
-
91
- # 纯名称: 在 tasks 目录下精确匹配
92
- tasks_dir = get_tasks_dir(repo_root)
93
- direct_match = tasks_dir / task_input
94
- if direct_match.is_dir():
95
- return direct_match.resolve()
96
-
97
- # 精确匹配失败 —— 查唯一前缀匹配 (不允许歧义)
98
- if tasks_dir.is_dir():
99
- candidates = [
100
- d for d in tasks_dir.iterdir()
101
- if d.is_dir() and (
102
- d.name == task_input or # 全名精确
103
- d.name.endswith("-" + task_input) or # "06-10-my-task".endswith("-my-task")
104
- d.name.startswith(task_input + "-") # "06-10".startswith("06-10-...")
105
- )
106
- ]
107
- if len(candidates) == 1:
108
- return candidates[0].resolve()
109
- if len(candidates) > 1:
110
- names = ", ".join(d.name for d in candidates[:5])
111
- raise ValueError(
112
- "任务名 '%s' 歧义, 匹配到 %d 个: %s。请用完整任务名。"
113
- % (task_input, len(candidates), names)
114
- )
115
-
116
- # 不存在 —— 返回原始路径 (调用方决定怎么报错)
117
- return tasks_dir / task_input
118
-
119
-
120
- # =============================================================================
121
- # 任务 JSON 读写
122
- # =============================================================================
123
-
124
- def load_task_json(task_dir: Path) -> dict | None:
125
- """加载 task.json 文件。
126
-
127
- Args:
128
- task_dir: 任务目录路径。
129
-
130
- Returns:
131
- 解析后的 dict, 文件不存在或解析失败返回 None。
132
-
133
- 健壮性 (v3.1.2): 改走 safe_read_json —— JSON 损坏时备份为
134
- task.json.corrupt.<ts> 并 stderr 告警, 而非静默吞成 None (与"文件
135
- 缺失"无法区分 → 数据丢失无恢复路径)。调用方行为不变 (仍返回 None)。
136
- """
137
- task_json = task_dir / FILE_TASK_JSON
138
- try:
139
- # safe_read_json: 缺失返回 default(None); 损坏备份 .corrupt 后抛 AtomicIOError。
140
- # 复用文件顶部已 import 的 atomicio (相对导入, 与 atomic_write_json 同源)。
141
- return safe_read_json(str(task_json), default=None)
142
- except AtomicIOError as e:
143
- # 损坏已备份, 告警但保持向后兼容 (返回 None, 调用方按"无任务"处理)
144
- print('Warning: %s' % e, file=sys.stderr)
145
- return None
146
- except (OSError, IOError):
147
- return None
148
-
149
-
150
- def write_task_json(task_dir: Path, data: dict) -> bool:
151
- """原子写入 task.json 文件 (temp + os.replace, 写失败原文件不变)。
152
-
153
- Args:
154
- task_dir: 任务目录路径。
155
- data: 要写入的数据。
156
-
157
- Returns:
158
- 成功返回 True。
159
- """
160
- task_json = task_dir / FILE_TASK_JSON
161
- try:
162
- # 原子写: sort_keys=False 保留插入顺序 (task.json 字段顺序有意义)
163
- atomic_write_json(
164
- str(task_json), data,
165
- indent=2, ensure_ascii=False, sort_keys=False,
166
- )
167
- return True
168
- except (OSError, IOError) as e:
169
- print(f"Warning: Failed to write task.json: {e}", file=sys.stderr)
170
- return False
171
-
172
-
173
- def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
174
- """在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
175
-
176
- 解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
177
- 锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
178
-
179
- Args:
180
- task_dir: 任务目录。
181
- mutator: 接收 data dict, 就地修改它 (返回值忽略)。
182
- timeout: 获取锁的等待秒数。
183
-
184
- Returns:
185
- 成功 (修改并写入) 返回 True; 加载失败返回 False。
186
- """
187
- try:
188
- from foundation.io.filelock import FileLock, LockTimeoutError
189
- except ImportError:
190
- # filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
191
- data = load_task_json(task_dir)
192
- if data is None:
193
- return False
194
- mutator(data)
195
- return write_task_json(task_dir, data)
196
-
197
- lock_path = task_dir / ".task.lock"
198
- lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
199
-
200
- # 锁竞争超时: 退避重试一次。绝不降级为无锁写 —— modify_task_json 存在的意义
201
- # 就是防并发 read-modify-write 丢更新, 两个并发方都超时降级 = 必然丢一个的更新。
202
- acquired = False
203
- for attempt in range(2):
204
- try:
205
- lock.acquire()
206
- acquired = True
207
- break
208
- except LockTimeoutError as e:
209
- if attempt == 0:
210
- # 第一次超时: 短暂退避后重试 (锁持有方通常很快释放)
211
- import time as _time
212
- _time.sleep(0.5)
213
- continue
214
- # 重试仍超时: 明确失败, 不写 (宁可这次改不了, 也不丢更新)
215
- print(f"ERROR: task lock busy after retry ({task_dir.name}): {e}", file=sys.stderr)
216
- print(f" 另一个进程正在修改此任务, 请稍后重试。改动未生效 (未丢数据)。", file=sys.stderr)
217
- return False
218
- if not acquired:
219
- return False
220
-
221
- try:
222
- data = load_task_json(task_dir)
223
- if data is None:
224
- return False
225
- mutator(data)
226
- return write_task_json(task_dir, data)
227
- finally:
228
- lock.release()
229
-
230
-
231
- # =============================================================================
232
- # ACL: 任务操作权限校验 (零信任)
233
- # =============================================================================
234
-
235
- def assert_can_modify_task(
236
- task_dir: Path,
237
- action: str,
238
- repo_root: Path | None = None,
239
- ) -> str:
240
- """校验当前开发者是否有权修改该任务。
241
-
242
- 零信任 ACL: 只有 creator / assignee / admin 角色能修改。
243
- 其他人 start/finish/archive 别人的任务会被拒绝。
244
-
245
- Args:
246
- task_dir: 任务目录。
247
- action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
248
- repo_root: 项目根。
249
-
250
- Returns:
251
- 当前开发者名 (校验通过)。
252
-
253
- Raises:
254
- PermissionError: 无权操作。
255
- """
256
- if repo_root is None:
257
- repo_root = get_repo_root()
258
- repo_root = Path(repo_root) # 统一为 Path
259
-
260
- # 读当前开发者
261
- try:
262
- from foundation.core.paths import get_developer
263
- except ImportError:
264
- from paths import get_developer # type: ignore
265
- dev = get_developer(repo_root)
266
- if not dev:
267
- raise PermissionError(
268
- "[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
269
- )
270
-
271
- task = load_task_json(task_dir)
272
- if not task:
273
- # 任务不存在 —— 让上层处理, 这里放行 (创建场景)
274
- return dev
275
-
276
- creator = (task.get("creator") or "").strip()
277
- assignee = (task.get("assignee") or "").strip()
278
- authorized = {creator, assignee}
279
- authorized.discard("")
280
-
281
- # admin 角色可操作任意任务
282
- try:
283
- from foundation.identity.identity import get_member
284
- m = get_member(dev, repo_root)
285
- if m and m.get("role") == "admin":
286
- return dev
287
- except Exception:
288
- pass # identity 模块不可用则退化为只看 creator/assignee
289
-
290
- if dev not in authorized:
291
- raise PermissionError(
292
- "[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
293
- "只有任务负责人或 admin 可操作。"
294
- % (action, task_dir.name, dev, creator or "?", assignee or "?")
295
- )
296
- return dev
297
-
298
-
299
- # =============================================================================
300
- # 生命周期钩子
301
- # =============================================================================
302
-
303
- def run_task_hooks(
304
- hook_name: str,
305
- task_json_path: Path,
306
- repo_root: Path | None = None,
307
- ) -> None:
308
- """运行任务生命周期钩子。
309
-
310
- 从 config.yaml 读取钩子配置并执行。
311
-
312
- Args:
313
- hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
314
- task_json_path: task.json 的路径。
315
- repo_root: 项目根目录, 默认自动检测。
316
- """
317
- if repo_root is None:
318
- repo_root = get_repo_root()
319
-
320
- config_path = repo_root / ".qoder" / "config.yaml"
321
- if not config_path.is_file():
322
- return
323
-
324
- # 简单的 YAML 解析 (不依赖 PyYAML)
325
- try:
326
- import yaml
327
- with open(config_path, "r", encoding="utf-8") as f:
328
- config = yaml.safe_load(f)
329
- except ImportError:
330
- # 没有 PyYAML, 用简单解析
331
- config = _simple_yaml_parse(config_path)
332
- except Exception as e:
333
- # config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
334
- # (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
335
- print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
336
- return
337
-
338
- if not config:
339
- return
340
-
341
- hooks = config.get("hooks", {})
342
- commands = hooks.get(hook_name, [])
343
- if not commands:
344
- return
345
-
346
- env = os.environ.copy()
347
- env["TASK_JSON_PATH"] = str(task_json_path)
348
-
349
- for cmd in commands:
350
- try:
351
- print(f" Running hook [{hook_name}]: {cmd}")
352
- result = subprocess.run(
353
- cmd,
354
- shell=True,
355
- cwd=str(repo_root),
356
- env=env,
357
- capture_output=True,
358
- text=True,
359
- timeout=30,
360
- )
361
- if result.returncode != 0:
362
- print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
363
- except subprocess.TimeoutExpired:
364
- print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
365
- except Exception as e:
366
- print(f" Warning: Hook error: {e}", file=sys.stderr)
367
-
368
-
369
- def _simple_yaml_parse(path: Path) -> dict:
370
- """简单的 YAML 解析器 (不依赖外部库)。
371
-
372
- 只支持两级嵌套和列表。
373
-
374
- Args:
375
- path: YAML 文件路径。
376
-
377
- Returns:
378
- 解析后的 dict。
379
- """
380
- result = {}
381
- current_key = None
382
- current_list = None
383
-
384
- try:
385
- lines = path.read_text(encoding="utf-8").splitlines()
386
- except (OSError, IOError):
387
- return result
388
-
389
- for line in lines:
390
- stripped = line.strip()
391
- if not stripped or stripped.startswith("#"):
392
- continue
393
-
394
- indent = len(line) - len(line.lstrip())
395
-
396
- if indent == 0 and ":" in stripped and not stripped.startswith("-"):
397
- # 顶层 key
398
- if current_key and current_list is not None:
399
- result[current_key] = current_list
400
- key, _, value = stripped.partition(":")
401
- current_key = key.strip()
402
- current_list = None
403
- if value.strip():
404
- result[current_key] = value.strip()
405
- current_key = None
406
-
407
- elif indent > 0 and stripped.startswith("- "):
408
- # 列表项
409
- if current_list is None:
410
- current_list = []
411
- item = stripped[2:].strip().strip("'\"")
412
- current_list.append(item)
413
-
414
- elif indent > 0 and ":" in stripped and current_key:
415
- # 子键值对
416
- if current_list is not None:
417
- result[current_key] = current_list
418
- current_list = None
419
- key, _, value = stripped.partition(":")
420
- if isinstance(result.get(current_key), dict):
421
- result[current_key][key.strip()] = value.strip().strip("'\"")
422
- else:
423
- result[current_key] = {key.strip(): value.strip().strip("'\"")}
424
-
425
- if current_key and current_list is not None:
426
- result[current_key] = current_list
427
-
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ QODER Pipeline - 任务工具函数
5
+
6
+ 提供:
7
+ - resolve_task_dir: 解析任务目录 (支持名称/相对路径/绝对路径)
8
+ - run_task_hooks: 运行任务生命周期钩子
9
+ - load_task_json: 加载 task.json
10
+ - write_task_json: 写入 task.json
11
+
12
+ 参考: common/task_utils.py 设计
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from foundation.core.paths import DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_tasks_dir
24
+
25
+ # 原子写 (解决半写损坏)
26
+ try:
27
+ from foundation.io.atomicio import atomic_write_json
28
+ except ImportError:
29
+ def atomic_write_json(path, data, indent=2, ensure_ascii=False, sort_keys=True):
30
+ import json as _j
31
+ Path(path).write_text(_j.dumps(data, indent=indent, ensure_ascii=ensure_ascii,
32
+ sort_keys=sort_keys) + "\n", encoding="utf-8")
33
+
34
+ # 安全读 (区分缺失/损坏, 损坏备份 .corrupt) — 与 atomic_write_json 同源
35
+ try:
36
+ from foundation.io.atomicio import safe_read_json, AtomicIOError
37
+ except ImportError:
38
+ # 兜底: 退化回旧的裸读 (损坏静默返回 None, 不备份)
39
+ def safe_read_json(path, default=None, required=False, backup_corrupt=True):
40
+ if not os.path.isfile(path):
41
+ return default
42
+ try:
43
+ return json.loads(Path(path).read_text(encoding="utf-8"))
44
+ except (ValueError, json.JSONDecodeError, OSError, IOError):
45
+ return default
46
+
47
+ class AtomicIOError(Exception):
48
+ """兜底类型 (仅当 atomicio 不可用时定义, 不会真正抛出)。"""
49
+
50
+
51
+ # =============================================================================
52
+ # 任务目录解析
53
+ # =============================================================================
54
+
55
+ def resolve_task_dir(task_input: str, repo_root: Path | None = None) -> Path:
56
+ """解析任务目录路径。
57
+
58
+ 支持三种输入:
59
+ 1. 纯名称/全名: "my-task" / "06-10-my-task" -> workspace/tasks/<name>
60
+ 2. 相对路径: "workspace/tasks/06-10-my-task" -> 完整路径
61
+ 3. 绝对路径: 直接使用
62
+
63
+ 安全变更 (零信任): 不再做模糊子串匹配 (旧版 "a" 可能匹配到他人的
64
+ "06-10-alpha" 导致误操作)。现在要求精确名或唯一前缀。
65
+
66
+ Args:
67
+ task_input: 任务标识 (名称或路径)。
68
+ repo_root: 项目根目录, 默认自动检测。
69
+
70
+ Returns:
71
+ 任务目录的绝对路径。
72
+
73
+ Raises:
74
+ FileNotFoundError: 找不到任务。
75
+ ValueError: 名称歧义 (多个候选)。
76
+ """
77
+ if repo_root is None:
78
+ repo_root = get_repo_root()
79
+
80
+ input_path = Path(task_input)
81
+
82
+ # 已经是绝对路径且存在
83
+ if input_path.is_absolute() and input_path.is_dir():
84
+ return input_path.resolve()
85
+
86
+ # 相对路径
87
+ full_from_root = (repo_root / task_input).resolve()
88
+ if full_from_root.is_dir():
89
+ return full_from_root
90
+
91
+ # 纯名称: 在 tasks 目录下精确匹配
92
+ tasks_dir = get_tasks_dir(repo_root)
93
+ direct_match = tasks_dir / task_input
94
+ if direct_match.is_dir():
95
+ return direct_match.resolve()
96
+
97
+ # 精确匹配失败 —— 查唯一前缀匹配 (不允许歧义)
98
+ if tasks_dir.is_dir():
99
+ candidates = [
100
+ d for d in tasks_dir.iterdir()
101
+ if d.is_dir() and (
102
+ d.name == task_input or # 全名精确
103
+ d.name.endswith("-" + task_input) or # "06-10-my-task".endswith("-my-task")
104
+ d.name.startswith(task_input + "-") # "06-10".startswith("06-10-...")
105
+ )
106
+ ]
107
+ if len(candidates) == 1:
108
+ return candidates[0].resolve()
109
+ if len(candidates) > 1:
110
+ names = ", ".join(d.name for d in candidates[:5])
111
+ raise ValueError(
112
+ "任务名 '%s' 歧义, 匹配到 %d 个: %s。请用完整任务名。"
113
+ % (task_input, len(candidates), names)
114
+ )
115
+
116
+ # 不存在 —— 返回原始路径 (调用方决定怎么报错)
117
+ return tasks_dir / task_input
118
+
119
+
120
+ # =============================================================================
121
+ # 任务 JSON 读写
122
+ # =============================================================================
123
+
124
+ def load_task_json(task_dir: Path) -> dict | None:
125
+ """加载 task.json 文件。
126
+
127
+ Args:
128
+ task_dir: 任务目录路径。
129
+
130
+ Returns:
131
+ 解析后的 dict, 文件不存在或解析失败返回 None。
132
+
133
+ 健壮性 (v3.1.2): 改走 safe_read_json —— JSON 损坏时备份为
134
+ task.json.corrupt.<ts> 并 stderr 告警, 而非静默吞成 None (与"文件
135
+ 缺失"无法区分 → 数据丢失无恢复路径)。调用方行为不变 (仍返回 None)。
136
+ """
137
+ task_json = task_dir / FILE_TASK_JSON
138
+ try:
139
+ # safe_read_json: 缺失返回 default(None); 损坏备份 .corrupt 后抛 AtomicIOError。
140
+ # 复用文件顶部已 import 的 atomicio (相对导入, 与 atomic_write_json 同源)。
141
+ return safe_read_json(str(task_json), default=None)
142
+ except AtomicIOError as e:
143
+ # 损坏已备份, 告警但保持向后兼容 (返回 None, 调用方按"无任务"处理)
144
+ print('Warning: %s' % e, file=sys.stderr)
145
+ return None
146
+ except (OSError, IOError):
147
+ return None
148
+
149
+
150
+ def write_task_json(task_dir: Path, data: dict) -> bool:
151
+ """原子写入 task.json 文件 (temp + os.replace, 写失败原文件不变)。
152
+
153
+ Args:
154
+ task_dir: 任务目录路径。
155
+ data: 要写入的数据。
156
+
157
+ Returns:
158
+ 成功返回 True。
159
+ """
160
+ task_json = task_dir / FILE_TASK_JSON
161
+ try:
162
+ # 原子写: sort_keys=False 保留插入顺序 (task.json 字段顺序有意义)
163
+ atomic_write_json(
164
+ str(task_json), data,
165
+ indent=2, ensure_ascii=False, sort_keys=False,
166
+ )
167
+ return True
168
+ except (OSError, IOError) as e:
169
+ print(f"Warning: Failed to write task.json: {e}", file=sys.stderr)
170
+ return False
171
+
172
+
173
+ def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
174
+ """在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
175
+
176
+ 解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
177
+ 锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
178
+
179
+ Args:
180
+ task_dir: 任务目录。
181
+ mutator: 接收 data dict, 就地修改它 (返回值忽略)。
182
+ timeout: 获取锁的等待秒数。
183
+
184
+ Returns:
185
+ 成功 (修改并写入) 返回 True; 加载失败返回 False。
186
+ """
187
+ try:
188
+ from foundation.io.filelock import FileLock, LockTimeoutError
189
+ except ImportError:
190
+ # filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
191
+ data = load_task_json(task_dir)
192
+ if data is None:
193
+ return False
194
+ mutator(data)
195
+ return write_task_json(task_dir, data)
196
+
197
+ lock_path = task_dir / ".task.lock"
198
+ lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
199
+
200
+ # 锁竞争超时: 退避重试一次。绝不降级为无锁写 —— modify_task_json 存在的意义
201
+ # 就是防并发 read-modify-write 丢更新, 两个并发方都超时降级 = 必然丢一个的更新。
202
+ acquired = False
203
+ for attempt in range(2):
204
+ try:
205
+ lock.acquire()
206
+ acquired = True
207
+ break
208
+ except LockTimeoutError as e:
209
+ if attempt == 0:
210
+ # 第一次超时: 短暂退避后重试 (锁持有方通常很快释放)
211
+ import time as _time
212
+ _time.sleep(0.5)
213
+ continue
214
+ # 重试仍超时: 明确失败, 不写 (宁可这次改不了, 也不丢更新)
215
+ print(f"ERROR: task lock busy after retry ({task_dir.name}): {e}", file=sys.stderr)
216
+ print(f" 另一个进程正在修改此任务, 请稍后重试。改动未生效 (未丢数据)。", file=sys.stderr)
217
+ return False
218
+ if not acquired:
219
+ return False
220
+
221
+ try:
222
+ data = load_task_json(task_dir)
223
+ if data is None:
224
+ return False
225
+ mutator(data)
226
+ return write_task_json(task_dir, data)
227
+ finally:
228
+ lock.release()
229
+
230
+
231
+ # =============================================================================
232
+ # ACL: 任务操作权限校验 (零信任)
233
+ # =============================================================================
234
+
235
+ def assert_can_modify_task(
236
+ task_dir: Path,
237
+ action: str,
238
+ repo_root: Path | None = None,
239
+ ) -> str:
240
+ """校验当前开发者是否有权修改该任务。
241
+
242
+ 零信任 ACL: 只有 creator / assignee / admin 角色能修改。
243
+ 其他人 start/finish/archive 别人的任务会被拒绝。
244
+
245
+ Args:
246
+ task_dir: 任务目录。
247
+ action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
248
+ repo_root: 项目根。
249
+
250
+ Returns:
251
+ 当前开发者名 (校验通过)。
252
+
253
+ Raises:
254
+ PermissionError: 无权操作。
255
+ """
256
+ if repo_root is None:
257
+ repo_root = get_repo_root()
258
+ repo_root = Path(repo_root) # 统一为 Path
259
+
260
+ # 读当前开发者
261
+ try:
262
+ from foundation.core.paths import get_developer
263
+ except ImportError:
264
+ from paths import get_developer # type: ignore
265
+ dev = get_developer(repo_root)
266
+ if not dev:
267
+ raise PermissionError(
268
+ "[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
269
+ )
270
+
271
+ task = load_task_json(task_dir)
272
+ if not task:
273
+ # 任务不存在 —— 让上层处理, 这里放行 (创建场景)
274
+ return dev
275
+
276
+ creator = (task.get("creator") or "").strip()
277
+ assignee = (task.get("assignee") or "").strip()
278
+ authorized = {creator, assignee}
279
+ authorized.discard("")
280
+
281
+ # admin 角色可操作任意任务
282
+ try:
283
+ from foundation.identity.identity import get_member
284
+ m = get_member(dev, repo_root)
285
+ if m and m.get("role") == "admin":
286
+ return dev
287
+ except Exception:
288
+ pass # identity 模块不可用则退化为只看 creator/assignee
289
+
290
+ if dev not in authorized:
291
+ raise PermissionError(
292
+ "[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
293
+ "只有任务负责人或 admin 可操作。"
294
+ % (action, task_dir.name, dev, creator or "?", assignee or "?")
295
+ )
296
+ return dev
297
+
298
+
299
+ # =============================================================================
300
+ # 生命周期钩子
301
+ # =============================================================================
302
+
303
+ def run_task_hooks(
304
+ hook_name: str,
305
+ task_json_path: Path,
306
+ repo_root: Path | None = None,
307
+ ) -> None:
308
+ """运行任务生命周期钩子。
309
+
310
+ 从 config.yaml 读取钩子配置并执行。
311
+
312
+ Args:
313
+ hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
314
+ task_json_path: task.json 的路径。
315
+ repo_root: 项目根目录, 默认自动检测。
316
+ """
317
+ if repo_root is None:
318
+ repo_root = get_repo_root()
319
+
320
+ config_path = repo_root / ".qoder" / "config.yaml"
321
+ if not config_path.is_file():
322
+ return
323
+
324
+ # 简单的 YAML 解析 (不依赖 PyYAML)
325
+ try:
326
+ import yaml
327
+ with open(config_path, "r", encoding="utf-8") as f:
328
+ config = yaml.safe_load(f)
329
+ except ImportError:
330
+ # 没有 PyYAML, 用简单解析
331
+ config = _simple_yaml_parse(config_path)
332
+ except Exception as e:
333
+ # config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
334
+ # (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
335
+ print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
336
+ return
337
+
338
+ if not config:
339
+ return
340
+
341
+ hooks = config.get("hooks", {})
342
+ commands = hooks.get(hook_name, [])
343
+ if not commands:
344
+ return
345
+
346
+ env = os.environ.copy()
347
+ env["TASK_JSON_PATH"] = str(task_json_path)
348
+
349
+ for cmd in commands:
350
+ try:
351
+ print(f" Running hook [{hook_name}]: {cmd}")
352
+ result = subprocess.run(
353
+ cmd,
354
+ shell=True,
355
+ cwd=str(repo_root),
356
+ env=env,
357
+ capture_output=True,
358
+ text=True,
359
+ timeout=30,
360
+ )
361
+ if result.returncode != 0:
362
+ print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
363
+ except subprocess.TimeoutExpired:
364
+ print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
365
+ except Exception as e:
366
+ print(f" Warning: Hook error: {e}", file=sys.stderr)
367
+
368
+
369
+ def _simple_yaml_parse(path: Path) -> dict:
370
+ """简单的 YAML 解析器 (不依赖外部库)。
371
+
372
+ 只支持两级嵌套和列表。
373
+
374
+ Args:
375
+ path: YAML 文件路径。
376
+
377
+ Returns:
378
+ 解析后的 dict。
379
+ """
380
+ result = {}
381
+ current_key = None
382
+ current_list = None
383
+
384
+ try:
385
+ lines = path.read_text(encoding="utf-8").splitlines()
386
+ except (OSError, IOError):
387
+ return result
388
+
389
+ for line in lines:
390
+ stripped = line.strip()
391
+ if not stripped or stripped.startswith("#"):
392
+ continue
393
+
394
+ indent = len(line) - len(line.lstrip())
395
+
396
+ if indent == 0 and ":" in stripped and not stripped.startswith("-"):
397
+ # 顶层 key
398
+ if current_key and current_list is not None:
399
+ result[current_key] = current_list
400
+ key, _, value = stripped.partition(":")
401
+ current_key = key.strip()
402
+ current_list = None
403
+ if value.strip():
404
+ result[current_key] = value.strip()
405
+ current_key = None
406
+
407
+ elif indent > 0 and stripped.startswith("- "):
408
+ # 列表项
409
+ if current_list is None:
410
+ current_list = []
411
+ item = stripped[2:].strip().strip("'\"")
412
+ current_list.append(item)
413
+
414
+ elif indent > 0 and ":" in stripped and current_key:
415
+ # 子键值对
416
+ if current_list is not None:
417
+ result[current_key] = current_list
418
+ current_list = None
419
+ key, _, value = stripped.partition(":")
420
+ if isinstance(result.get(current_key), dict):
421
+ result[current_key][key.strip()] = value.strip().strip("'\"")
422
+ else:
423
+ result[current_key] = {key.strip(): value.strip().strip("'\"")}
424
+
425
+ if current_key and current_list is not None:
426
+ result[current_key] = current_list
427
+
428
428
  return result