@michaelxwb/code-flow 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -0
- package/src/adapters/claude/commands/cf-spec.md +7 -3
- package/src/adapters/claude/commands/cf-task/archive.md +2 -0
- package/src/adapters/claude/commands/cf-task/graph.md +1 -1
- package/src/adapters/claude/commands/cf-task/plan.md +2 -2
- package/src/adapters/claude/commands/cf-task/start.md +99 -10
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +6 -1
- package/src/adapters/claude/commands/cf-validate.md +3 -0
- package/src/adapters/codex/skills/cf-init/SKILL.md +1 -0
- package/src/adapters/codex/skills/cf-spec/SKILL.md +6 -2
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +2 -0
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +1 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +2 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +99 -10
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +6 -1
- package/src/adapters/codex/skills/cf-validate/SKILL.md +3 -0
- package/src/adapters/costrict/commands/cf-init.md +1 -0
- package/src/adapters/costrict/commands/cf-spec.md +7 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +2 -0
- package/src/adapters/costrict/commands/cf-task/graph.md +1 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +2 -2
- package/src/adapters/costrict/commands/cf-task/start.md +99 -10
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +6 -1
- package/src/adapters/costrict/commands/cf-validate.md +3 -0
- package/src/adapters/opencode/commands/cf-init.md +9 -4
- package/src/adapters/opencode/commands/cf-spec.md +6 -2
- package/src/adapters/opencode/commands/cf-task/archive.md +2 -0
- package/src/adapters/opencode/commands/cf-task/graph.md +1 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +2 -2
- package/src/adapters/opencode/commands/cf-task/start.md +99 -10
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +6 -1
- package/src/adapters/opencode/commands/cf-validate.md +3 -0
- package/src/adapters/opencode/opencode.json +1 -2
- package/src/adapters/opencode/plugins/code-flow/index.js +138 -78
- package/src/adapters/opencode/plugins/code-flow/package.json +1 -1
- package/src/cli.js +51 -1
- package/src/core/code-flow/config.yml +2 -1
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +54 -8
- package/src/core/code-flow/scripts/cf_core.py +28 -1
- package/src/core/code-flow/scripts/cf_post_hook.py +5 -4
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +14 -3
- package/src/core/code-flow/scripts/cf_spec_context.py +53 -16
- package/src/core/code-flow/scripts/cf_spec_gate.py +28 -4
- package/src/core/code-flow/scripts/cf_spec_metadata.py +34 -3
- package/src/core/code-flow/scripts/cf_spec_router.py +1 -1
- package/src/core/code-flow/scripts/cf_spec_verify.py +107 -9
- package/src/core/code-flow/scripts/cf_stop_hook.py +31 -3
- package/src/core/code-flow/scripts/cf_task_index.py +5 -4
- package/src/core/code-flow/scripts/cf_task_parallel.py +394 -0
- package/src/core/code-flow/scripts/cf_task_runtime.py +49 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +111 -13
- package/src/core/code-flow/validation.yml +5 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task-level worktree orchestration for parallel batch execution.
|
|
3
|
+
|
|
4
|
+
`cf-task:start` 整文件模式对拓扑批次内的独立 TASK 并行派发子 agent 时,本脚本
|
|
5
|
+
负责可测试的确定性步骤,子 agent 派发与合并冲突解决仍由命令提示词编排:
|
|
6
|
+
|
|
7
|
+
prepare 预检(git 仓库 / worktree 忽略规则 / 无 active marker / tracked 干净)
|
|
8
|
+
→ 自动提交仅位于需求目录内的流程产物 → 校验任务可并行 → 建 worktree+分支
|
|
9
|
+
collect 回并前校验:改动已提交、Status 已 done/verified、marker 已清理
|
|
10
|
+
cleanup 合并完成后移除 worktree;已合入的分支一并删除,未合入的保留可追溯
|
|
11
|
+
|
|
12
|
+
协议(详见 cf-task:start 命令正文):
|
|
13
|
+
- worktree: .code-flow/worktrees/<run-id>/<TASK-ID>/
|
|
14
|
+
- branch: cf-task/<slug>/<run-id>/<TASK-ID>
|
|
15
|
+
- 元数据: .code-flow/worktrees/<run-id>/run.json(base head / 任务清单)
|
|
16
|
+
|
|
17
|
+
stdout 始终为 JSON:{"ok": true, ...} 或 {"ok": false, "code": ..., "message": ...}。
|
|
18
|
+
预检失败时命令方回退串行,不得手工绕过本脚本建 worktree/分支。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import IO, Optional, Sequence
|
|
31
|
+
|
|
32
|
+
from cf_task_index import parse_task_file
|
|
33
|
+
from cf_task_index import task_batches
|
|
34
|
+
from cf_task_state import FINISHED_STATUSES
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
WORKTREES_DIR = ".code-flow/worktrees"
|
|
38
|
+
MARKER = ".code-flow/.active-task.json"
|
|
39
|
+
GITIGNORE = ".code-flow/.gitignore"
|
|
40
|
+
_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
41
|
+
_TASK_ID = re.compile(r"^TASK-\d+$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ParallelError(ValueError):
|
|
45
|
+
"""Precondition or protocol violation with a stable machine code."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, code: str, message: str) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.code = code
|
|
50
|
+
self.message = message
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_git(root: Path, arguments: Sequence[str]) -> str:
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
("git", *arguments), cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False
|
|
56
|
+
)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
message = result.stderr.strip() or "Git command failed"
|
|
59
|
+
raise ParallelError("git_error", message)
|
|
60
|
+
return result.stdout
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _assert_repo(root: Path) -> None:
|
|
64
|
+
try:
|
|
65
|
+
top = _run_git(root, ("rev-parse", "--show-toplevel")).strip()
|
|
66
|
+
except ParallelError as exc:
|
|
67
|
+
raise ParallelError("git_unavailable", "并行执行需要 git 仓库") from exc
|
|
68
|
+
if Path(top).resolve() != root:
|
|
69
|
+
raise ParallelError("not_project_root", f"root 必须是仓库根目录: {top}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _status_paths(root: Path) -> list[str]:
|
|
73
|
+
output = _run_git(root, ("status", "--porcelain=v1", "-z", "--untracked-files=all"))
|
|
74
|
+
tokens = output.split("\0")
|
|
75
|
+
paths: list[str] = []
|
|
76
|
+
index = 0
|
|
77
|
+
while index < len(tokens) and tokens[index]:
|
|
78
|
+
record = tokens[index]
|
|
79
|
+
if len(record) < 4:
|
|
80
|
+
raise ParallelError("invalid_git_status", record)
|
|
81
|
+
code, path = record[:2], record[3:]
|
|
82
|
+
paths.append(path)
|
|
83
|
+
if code[:1] in ("R", "C"):
|
|
84
|
+
if index + 1 < len(tokens) and tokens[index + 1]:
|
|
85
|
+
paths.append(tokens[index + 1])
|
|
86
|
+
index += 2
|
|
87
|
+
else:
|
|
88
|
+
index += 1
|
|
89
|
+
return paths
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_ignored(root: Path) -> None:
|
|
93
|
+
ignore = root / GITIGNORE
|
|
94
|
+
try:
|
|
95
|
+
text = ignore.read_text(encoding="utf-8")
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
raise ParallelError("worktrees_not_ignored", f"缺少 {GITIGNORE}: {exc}") from exc
|
|
98
|
+
if "worktrees/" not in text.split():
|
|
99
|
+
raise ParallelError(
|
|
100
|
+
"worktrees_not_ignored", f"{GITIGNORE} 缺少 worktrees/ 规则,请先运行 code-flow init 升级"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _require_no_active(root: Path) -> None:
|
|
105
|
+
if (root / MARKER).exists():
|
|
106
|
+
raise ParallelError("active_exists", "主 worktree 已有 active TASK,先 finish/doctor 后再并行")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _commit_task_dir(root: Path, task_file: str) -> Optional[str]:
|
|
110
|
+
task_dir = Path(task_file).parent
|
|
111
|
+
entries = _status_paths(root)
|
|
112
|
+
outside = [
|
|
113
|
+
path
|
|
114
|
+
for path in entries
|
|
115
|
+
if not (path == str(task_dir) or path.startswith(f"{task_dir}/"))
|
|
116
|
+
]
|
|
117
|
+
if outside:
|
|
118
|
+
sample = ", ".join(outside[:8]) + (" ..." if len(outside) > 8 else "")
|
|
119
|
+
raise ParallelError(
|
|
120
|
+
"dirty_tree",
|
|
121
|
+
"并行要求 tracked 工作区干净(需求目录以外仍有改动: "
|
|
122
|
+
f"{sample})。请先提交(git add -A && git commit -m 'wip')"
|
|
123
|
+
"或暂存(git stash push -u)后重试;需求目录内的流程产物会自动提交。",
|
|
124
|
+
)
|
|
125
|
+
if not entries:
|
|
126
|
+
return None
|
|
127
|
+
_run_git(root, ("add", "-A", "--", str(task_dir)))
|
|
128
|
+
_run_git(root, ("commit", "-q", "-m", f"chore(cf-task): plan {Path(task_file).stem}"))
|
|
129
|
+
return _run_git(root, ("rev-parse", "HEAD")).strip()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_batch(root: Path, task_file: str, tasks: Sequence[str]) -> str:
|
|
133
|
+
path = root / task_file
|
|
134
|
+
if not path.is_file():
|
|
135
|
+
raise ParallelError("task_file_missing", f"任务文件不存在: {task_file}")
|
|
136
|
+
try:
|
|
137
|
+
nodes = parse_task_file(str(path))
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise ParallelError("task_parse_error", str(exc)) from exc
|
|
140
|
+
by_id = {node.task_id: node for node in nodes}
|
|
141
|
+
for task in tasks:
|
|
142
|
+
if task not in by_id:
|
|
143
|
+
raise ParallelError("unknown_task", f"{task} 不在任务文件中")
|
|
144
|
+
if by_id[task].status != "draft":
|
|
145
|
+
raise ParallelError("task_not_draft", f"{task} 当前 Status 为 {by_id[task].status},不可并行激活")
|
|
146
|
+
try:
|
|
147
|
+
ready = task_batches(nodes)[0]
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise ParallelError("task_graph_error", str(exc)) from exc
|
|
150
|
+
not_ready = [task for task in tasks if task not in ready]
|
|
151
|
+
if not_ready:
|
|
152
|
+
raise ParallelError("task_not_ready", f"依赖未完成,本批不可激活: {', '.join(not_ready)}(可激活: {', '.join(ready)})")
|
|
153
|
+
return str(path)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _branch_slug(task_file: str) -> str:
|
|
157
|
+
stem = Path(task_file).stem
|
|
158
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._")
|
|
159
|
+
return slug[:40] or "batch"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _worktree_paths(root: Path, run_id: str, task: str, task_file: str) -> tuple[Path, str]:
|
|
163
|
+
branch = f"cf-task/{_branch_slug(task_file)}/{run_id}/{task}"
|
|
164
|
+
return root / WORKTREES_DIR / run_id / task, branch
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_id() -> str:
|
|
168
|
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _write_run_meta(run_dir: Path, payload: dict[str, object]) -> None:
|
|
172
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
(run_dir / "run.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _read_run_meta(root: Path, run_id: str) -> dict[str, object]:
|
|
177
|
+
meta = root / WORKTREES_DIR / run_id / "run.json"
|
|
178
|
+
try:
|
|
179
|
+
return json.loads(meta.read_text(encoding="utf-8"))
|
|
180
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
181
|
+
raise ParallelError("run_not_found", f"找不到并行记录 {meta}: {exc}") from exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _create_worktrees(root: Path, task_file: str, tasks: Sequence[str], run_id: str) -> dict[str, object]:
|
|
185
|
+
created: list[tuple[Path, str]] = []
|
|
186
|
+
base_head = _run_git(root, ("rev-parse", "HEAD")).strip()
|
|
187
|
+
try:
|
|
188
|
+
for task in tasks:
|
|
189
|
+
path, branch = _worktree_paths(root, run_id, task, task_file)
|
|
190
|
+
if path.exists() or _branch_exists(root, branch):
|
|
191
|
+
raise ParallelError("worktree_exists", f"{task} 的 worktree 或分支已存在: {branch}")
|
|
192
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
_run_git(root, ("worktree", "add", "-b", branch, str(path), "HEAD"))
|
|
194
|
+
if not (path / task_file).is_file():
|
|
195
|
+
raise ParallelError("task_file_missing", f"{task}: worktree 内缺少 {task_file}(需先提交任务文件)")
|
|
196
|
+
created.append((path, branch))
|
|
197
|
+
except ParallelError:
|
|
198
|
+
for path, branch in created:
|
|
199
|
+
_remove_worktree(root, path, force=True)
|
|
200
|
+
_run_git(root, ("branch", "-D", branch))
|
|
201
|
+
raise
|
|
202
|
+
return {"base_head": base_head, "created": created}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _branch_exists(root: Path, branch: str) -> bool:
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
("git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"),
|
|
208
|
+
cwd=str(root), capture_output=True, check=False,
|
|
209
|
+
)
|
|
210
|
+
return result.returncode == 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _delete_branch(root: Path, branch: str) -> bool:
|
|
214
|
+
result = subprocess.run(
|
|
215
|
+
("git", "branch", "-d", branch), cwd=str(root), capture_output=True, check=False,
|
|
216
|
+
)
|
|
217
|
+
return result.returncode == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _remove_worktree(root: Path, path: Path, force: bool) -> None:
|
|
221
|
+
arguments = ["worktree", "remove"]
|
|
222
|
+
if force:
|
|
223
|
+
arguments.append("--force")
|
|
224
|
+
arguments.append(str(path))
|
|
225
|
+
_run_git(root, tuple(arguments))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _relative_task_file(root: Path, task_file: str) -> str:
|
|
229
|
+
candidate = Path(task_file)
|
|
230
|
+
absolute = candidate if candidate.is_absolute() else root / candidate
|
|
231
|
+
try:
|
|
232
|
+
return str(absolute.resolve().relative_to(root))
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
raise ParallelError("task_file_outside_root", f"任务文件不在仓库内: {task_file}") from exc
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def prepare(root: Path, task_file: str, tasks: Sequence[str], run_id: Optional[str]) -> dict[str, object]:
|
|
238
|
+
root = root.resolve()
|
|
239
|
+
_assert_repo(root)
|
|
240
|
+
task_file = _relative_task_file(root, task_file)
|
|
241
|
+
_require_ignored(root)
|
|
242
|
+
_require_no_active(root)
|
|
243
|
+
identifier = run_id or _run_id()
|
|
244
|
+
if not _RUN_ID.match(identifier):
|
|
245
|
+
raise ParallelError("invalid_run_id", f"run-id 非法: {identifier}")
|
|
246
|
+
if not tasks:
|
|
247
|
+
raise ParallelError("no_tasks", "未指定并行 TASK")
|
|
248
|
+
if len(set(tasks)) != len(tasks):
|
|
249
|
+
raise ParallelError("duplicate_task", "并行 TASK 列表存在重复")
|
|
250
|
+
for task in tasks:
|
|
251
|
+
if not _TASK_ID.match(task):
|
|
252
|
+
raise ParallelError("invalid_task_id", f"TASK-ID 非法: {task}")
|
|
253
|
+
_validate_batch(root, task_file, tasks)
|
|
254
|
+
_commit_task_dir(root, task_file)
|
|
255
|
+
outcome = _create_worktrees(root, task_file, tasks, identifier)
|
|
256
|
+
worktrees = [
|
|
257
|
+
{"task": task, "branch": branch, "path": str(path)}
|
|
258
|
+
for task, (path, branch) in zip(tasks, outcome["created"])
|
|
259
|
+
]
|
|
260
|
+
_write_run_meta(
|
|
261
|
+
root / WORKTREES_DIR / identifier,
|
|
262
|
+
{"version": 1, "run_id": identifier, "task_file": task_file,
|
|
263
|
+
"base_head": outcome["base_head"], "worktrees": worktrees},
|
|
264
|
+
)
|
|
265
|
+
return {"ok": True, "action": "prepare", "run_id": identifier, "task_file": task_file,
|
|
266
|
+
"base_head": outcome["base_head"], "worktrees": worktrees}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _collect_one(root: Path, entry: dict[str, object], base_head: str, task_file: str, commit: bool = True) -> dict[str, object]:
|
|
270
|
+
task = str(entry["task"])
|
|
271
|
+
path = Path(str(entry["path"]))
|
|
272
|
+
branch = str(entry["branch"])
|
|
273
|
+
if not path.is_dir():
|
|
274
|
+
raise ParallelError("worktree_missing", f"{task}: worktree 不存在: {path}")
|
|
275
|
+
try:
|
|
276
|
+
nodes = parse_task_file(str(path / task_file))
|
|
277
|
+
except ValueError as exc:
|
|
278
|
+
raise ParallelError("task_parse_error", f"{task}: {exc}") from exc
|
|
279
|
+
status = next((node.status for node in nodes if node.task_id == task), None)
|
|
280
|
+
if status not in FINISHED_STATUSES:
|
|
281
|
+
raise ParallelError("task_not_finished", f"{task}: Status 为 {status},未通过 Done Gate")
|
|
282
|
+
if (path / MARKER).exists():
|
|
283
|
+
raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
|
|
284
|
+
dirty = _status_paths(path)
|
|
285
|
+
if dirty:
|
|
286
|
+
# finish 会在通过后回写 Evidence/状态;任务已 done 且 marker 已清理时,
|
|
287
|
+
# 这些回写由 collect 自动提交(--no-commit 可关闭,保持严格模式)。
|
|
288
|
+
if not commit:
|
|
289
|
+
raise ParallelError(
|
|
290
|
+
"worktree_dirty",
|
|
291
|
+
f"{task}: 尚有未提交改动: {', '.join(dirty[:8])};可在 worktree 内提交后重跑 collect,或去掉 --no-commit",
|
|
292
|
+
)
|
|
293
|
+
_run_git(path, ("add", "-A"))
|
|
294
|
+
_run_git(path, ("commit", "-q", "-m", f"cf-task({task}): finish 回写验收证据与任务状态"))
|
|
295
|
+
head = _run_git(path, ("rev-parse", "HEAD")).strip()
|
|
296
|
+
if head == base_head:
|
|
297
|
+
raise ParallelError("no_commits", f"{task}: worktree 无新提交")
|
|
298
|
+
files = _run_git(root, ("diff", "--name-only", f"{base_head}..{branch}")).splitlines()
|
|
299
|
+
return {"task": task, "branch": branch, "path": str(path), "head": head,
|
|
300
|
+
"status": status, "files": sorted(item for item in files if item)}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def collect(root: Path, run_id: str, tasks: Optional[Sequence[str]], commit: bool = True) -> dict[str, object]:
|
|
304
|
+
root = root.resolve()
|
|
305
|
+
meta = _read_run_meta(root, run_id)
|
|
306
|
+
entries = [item for item in meta.get("worktrees", []) if isinstance(item, dict)]
|
|
307
|
+
if tasks:
|
|
308
|
+
wanted = set(tasks)
|
|
309
|
+
entries = [item for item in entries if item.get("task") in wanted]
|
|
310
|
+
results: list[dict[str, object]] = []
|
|
311
|
+
for entry in entries:
|
|
312
|
+
try:
|
|
313
|
+
results.append(_collect_one(root, entry, str(meta["base_head"]), str(meta["task_file"]), commit))
|
|
314
|
+
except ParallelError as exc:
|
|
315
|
+
results.append({"task": str(entry.get("task", "")), "ok": False, "code": exc.code, "message": exc.message})
|
|
316
|
+
ok = bool(results) and all(item.get("code") is None for item in results)
|
|
317
|
+
return {"ok": ok, "action": "collect", "run_id": run_id, "results": results}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def cleanup(root: Path, run_id: str, force: bool) -> dict[str, object]:
|
|
321
|
+
root = root.resolve()
|
|
322
|
+
meta = _read_run_meta(root, run_id)
|
|
323
|
+
results: list[dict[str, object]] = []
|
|
324
|
+
for item in meta.get("worktrees", []):
|
|
325
|
+
if not isinstance(item, dict):
|
|
326
|
+
continue
|
|
327
|
+
path = Path(str(item.get("path", "")))
|
|
328
|
+
if not path.exists():
|
|
329
|
+
results.append({"task": item.get("task"), "removed": False, "reason": "already_absent"})
|
|
330
|
+
continue
|
|
331
|
+
try:
|
|
332
|
+
_require_clean_for_removal(path, force)
|
|
333
|
+
_remove_worktree(root, path, force)
|
|
334
|
+
branch = str(item.get("branch", ""))
|
|
335
|
+
merged = _delete_branch(root, branch) if branch else False
|
|
336
|
+
results.append({"task": item.get("task"), "removed": True, "branch_deleted": merged})
|
|
337
|
+
except ParallelError as exc:
|
|
338
|
+
results.append({"task": item.get("task"), "removed": False, "code": exc.code, "message": exc.message})
|
|
339
|
+
_run_git(root, ("worktree", "prune"))
|
|
340
|
+
run_dir = root / WORKTREES_DIR / run_id
|
|
341
|
+
if run_dir.is_dir() and not any(child.is_dir() for child in run_dir.iterdir()):
|
|
342
|
+
(run_dir / "run.json").unlink(missing_ok=True)
|
|
343
|
+
run_dir.rmdir()
|
|
344
|
+
ok = all(item.get("removed") or item.get("reason") == "already_absent" for item in results)
|
|
345
|
+
return {"ok": ok, "action": "cleanup", "run_id": run_id, "results": results}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _require_clean_for_removal(path: Path, force: bool) -> None:
|
|
349
|
+
if force:
|
|
350
|
+
return
|
|
351
|
+
dirty = _status_paths(path)
|
|
352
|
+
if dirty:
|
|
353
|
+
raise ParallelError("worktree_dirty", f"worktree 有未提交改动: {', '.join(dirty[:8])}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _emit(payload: dict[str, object], stdout: IO[str]) -> None:
|
|
357
|
+
stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
361
|
+
parser = argparse.ArgumentParser(prog="cf_task_parallel.py")
|
|
362
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
363
|
+
for name in ("prepare", "collect", "cleanup"):
|
|
364
|
+
command = sub.add_parser(name)
|
|
365
|
+
command.add_argument("--root", default=".")
|
|
366
|
+
command.add_argument("--json", action="store_true")
|
|
367
|
+
prepare_parser = sub.choices["prepare"]
|
|
368
|
+
prepare_parser.add_argument("--task-file", required=True)
|
|
369
|
+
prepare_parser.add_argument("--tasks", required=True)
|
|
370
|
+
prepare_parser.add_argument("--run-id", default="")
|
|
371
|
+
for name in ("collect", "cleanup"):
|
|
372
|
+
sub.choices[name].add_argument("--run-id", required=True)
|
|
373
|
+
sub.choices["collect"].add_argument("--tasks", default="")
|
|
374
|
+
sub.choices["collect"].add_argument("--no-commit", action="store_true")
|
|
375
|
+
sub.choices["cleanup"].add_argument("--force", action="store_true")
|
|
376
|
+
args = parser.parse_args(argv)
|
|
377
|
+
root = Path(args.root).resolve()
|
|
378
|
+
try:
|
|
379
|
+
if args.action == "prepare":
|
|
380
|
+
tasks = [item.strip() for item in args.tasks.split(",") if item.strip()]
|
|
381
|
+
payload = prepare(root, args.task_file, tasks, args.run_id or None)
|
|
382
|
+
elif args.action == "collect":
|
|
383
|
+
tasks = [item.strip() for item in args.tasks.split(",") if item.strip()] or None
|
|
384
|
+
payload = collect(root, args.run_id, tasks, commit=not args.no_commit)
|
|
385
|
+
else:
|
|
386
|
+
payload = cleanup(root, args.run_id, args.force)
|
|
387
|
+
except ParallelError as exc:
|
|
388
|
+
payload = {"ok": False, "action": args.action, "code": exc.code, "message": exc.message}
|
|
389
|
+
_emit(payload, stdout)
|
|
390
|
+
return 0 if payload.get("ok") else 2
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
raise SystemExit(main())
|
|
@@ -29,7 +29,7 @@ from cf_spec_metadata import load_spec_metadata
|
|
|
29
29
|
from cf_spec_resolver import resolve_candidates, SpecCandidate
|
|
30
30
|
from cf_spec_session import context_sha256
|
|
31
31
|
from cf_spec_verify import VerificationEvidence, VerificationScope, run_all_verifiers
|
|
32
|
-
from cf_core import phase_timing
|
|
32
|
+
from cf_core import load_config, phase_timing, resolve_quality_loop
|
|
33
33
|
from cf_acceptance_schema import load_manifest, validate_execution_baseline, verified_evidence
|
|
34
34
|
from cf_exec_base import execution_session
|
|
35
35
|
|
|
@@ -48,6 +48,7 @@ class DoneResult:
|
|
|
48
48
|
files: tuple[str, ...]
|
|
49
49
|
evidence: tuple[Mapping[str, object], ...]
|
|
50
50
|
message: str = ""
|
|
51
|
+
deferred_review: int = 0
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
def _context_path(task_dir: str) -> str:
|
|
@@ -153,10 +154,11 @@ def _evidence_data(evidence: VerificationEvidence) -> Mapping[str, object]:
|
|
|
153
154
|
|
|
154
155
|
|
|
155
156
|
def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBinding:
|
|
156
|
-
|
|
157
|
+
stage = rule.verifier_stage or "code"
|
|
158
|
+
if stage not in rule.stage_status:
|
|
157
159
|
return rule
|
|
158
160
|
statuses = dict(rule.stage_status)
|
|
159
|
-
current = statuses[
|
|
161
|
+
current = statuses[stage]
|
|
160
162
|
if current.status in ("not_applicable", "waived"):
|
|
161
163
|
return rule
|
|
162
164
|
status = "verified" if evidence.get("status") == "verified" else "unverified"
|
|
@@ -168,9 +170,9 @@ def _update_rule(rule: RuleBinding, evidence: Mapping[str, object]) -> RuleBindi
|
|
|
168
170
|
(item.get("verifier_ref"), item.get("result_sha256"), item.get("diff_sha256")) == signature
|
|
169
171
|
for item in current.evidence
|
|
170
172
|
):
|
|
171
|
-
statuses[
|
|
173
|
+
statuses[stage] = replace(current, status=status)
|
|
172
174
|
else:
|
|
173
|
-
statuses[
|
|
175
|
+
statuses[stage] = replace(current, status=status, evidence=(*current.evidence, evidence))
|
|
174
176
|
return replace(rule, stage_status=statuses)
|
|
175
177
|
|
|
176
178
|
|
|
@@ -201,13 +203,17 @@ def _rule_manual_confirmation(rule: RuleBinding) -> Optional[Mapping[str, object
|
|
|
201
203
|
|
|
202
204
|
|
|
203
205
|
def _run_acceptance(root: str, task_dir: str, owner: str, include_e2e: bool,
|
|
204
|
-
deadline: Optional[float]) -> str:
|
|
206
|
+
deadline: Optional[float], cheap: bool = False) -> str:
|
|
205
207
|
baseline_issue = _acceptance_baseline_issue(task_dir, owner)
|
|
206
208
|
if baseline_issue:
|
|
207
209
|
return baseline_issue
|
|
208
210
|
manual_issue = _manual_manifest_issue(task_dir, owner)
|
|
209
211
|
if manual_issue:
|
|
210
212
|
return manual_issue
|
|
213
|
+
if cheap:
|
|
214
|
+
# 轻量门禁:只做 manifest 基线与 manual 检查,不执行 functional 场景;
|
|
215
|
+
# 场景命令由 finish 的全量 Done Gate 执行。
|
|
216
|
+
return ""
|
|
211
217
|
manifest_path = Path(task_dir) / ".acceptance-manifest.json"
|
|
212
218
|
if manifest_path.is_file():
|
|
213
219
|
from cf_acceptance_runner import run_manifest
|
|
@@ -218,6 +224,32 @@ def _run_acceptance(root: str, task_dir: str, owner: str, include_e2e: bool,
|
|
|
218
224
|
return ""
|
|
219
225
|
|
|
220
226
|
|
|
227
|
+
def _run_finish_validation(root: str, files: tuple[str, ...]) -> str:
|
|
228
|
+
"""Full validation.yml (including heavy validators) once per TASK finish.
|
|
229
|
+
|
|
230
|
+
Stop-time checks stay cheap; this is the single automatic execution point
|
|
231
|
+
for slow suites (task finish `/cf-validate` scope). `quality_loop.finish_check:
|
|
232
|
+
false` disables it; projects without validation.yml / package.json scripts are a no-op.
|
|
233
|
+
"""
|
|
234
|
+
if not resolve_quality_loop(load_config(root)).get("finish_check"):
|
|
235
|
+
return ""
|
|
236
|
+
from cf_validation import validate_files
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
result = validate_files(root, files)
|
|
240
|
+
except (OSError, ValueError) as exc:
|
|
241
|
+
return f"finish validation error: {exc}"
|
|
242
|
+
reason = result.get("reason")
|
|
243
|
+
if result.get("decision") == "pass" or reason in ("no_changes", "no_validators_configured"):
|
|
244
|
+
return ""
|
|
245
|
+
failures = result.get("failures")
|
|
246
|
+
names = ", ".join(str(item.get("name", "validator")) for item in failures[:3]) if isinstance(failures, list) and failures else "validator"
|
|
247
|
+
if result.get("incomplete") and not failures:
|
|
248
|
+
return "finish validation 预算耗尽:部分 validator 未执行"
|
|
249
|
+
suffix = "(预算耗尽,部分未执行)" if result.get("incomplete") else ""
|
|
250
|
+
return f"finish validation failed{suffix}: {names}"
|
|
251
|
+
|
|
252
|
+
|
|
221
253
|
def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
|
|
222
254
|
active = load_active_task(root)
|
|
223
255
|
owner = task_id or active.task_id
|
|
@@ -229,7 +261,7 @@ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Option
|
|
|
229
261
|
if scope_result.decision == "pause":
|
|
230
262
|
return DoneResult("block", scope_result.files, (), scope_result.message)
|
|
231
263
|
deadline = started + budget if budget is not None else None
|
|
232
|
-
issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline)
|
|
264
|
+
issue = _run_acceptance(root, task_dir, owner, include_e2e, deadline, cheap)
|
|
233
265
|
if issue:
|
|
234
266
|
return DoneResult("block", scope_result.files, (), issue)
|
|
235
267
|
phase_started = time.monotonic()
|
|
@@ -237,9 +269,11 @@ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Option
|
|
|
237
269
|
diff_hash = _diff_hash(root, scope_result.files)
|
|
238
270
|
phase_timing("done.load_context_and_diff", phase_started)
|
|
239
271
|
all_evidence: list[Mapping[str, object]] = []
|
|
272
|
+
deferred_review = 0
|
|
240
273
|
for binding in context.bindings:
|
|
241
274
|
phase_started = time.monotonic()
|
|
242
275
|
metadata = load_spec_metadata(str(Path(root) / ".code-flow/specs" / binding.path))
|
|
276
|
+
deferred_review += sum(1 for rule in binding.rules if rule.verifier_stage == "review")
|
|
243
277
|
confirmations: dict[str, Mapping[str, object]] = {}
|
|
244
278
|
for rule in binding.rules:
|
|
245
279
|
confirmation = _rule_manual_confirmation(rule)
|
|
@@ -247,17 +281,22 @@ def _run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Option
|
|
|
247
281
|
confirmations[rule.ref] = confirmation
|
|
248
282
|
remaining = None if budget is None else budget - (time.monotonic() - started)
|
|
249
283
|
result = run_all_verifiers(
|
|
250
|
-
metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining
|
|
284
|
+
metadata, VerificationScope(root, scope_result.files, diff_hash), confirmations, cheap, remaining,
|
|
285
|
+
stage="code",
|
|
251
286
|
)
|
|
252
287
|
phase_timing(f"done.verify.{binding.spec_id}", phase_started)
|
|
253
288
|
all_evidence.extend(_evidence_data(item) for item in result.evidence)
|
|
254
289
|
updated = _apply_evidence(context, tuple(all_evidence))
|
|
255
290
|
if updated != context:
|
|
256
291
|
save_context(_context_path(task_dir), updated)
|
|
257
|
-
gate = validate_stage(updated, "code", diff_sha256=diff_hash)
|
|
292
|
+
gate = validate_stage(updated, "code", diff_sha256=diff_hash, allow_cheap_skips=cheap)
|
|
293
|
+
if gate.decision == "pass" and not cheap:
|
|
294
|
+
validation_issue = _run_finish_validation(root, scope_result.files)
|
|
295
|
+
if validation_issue:
|
|
296
|
+
return DoneResult("block", scope_result.files, tuple(all_evidence), validation_issue, deferred_review)
|
|
258
297
|
phase_timing("done.total", started)
|
|
259
298
|
return DoneResult(gate.decision, scope_result.files, tuple(all_evidence),
|
|
260
|
-
"; ".join(issue.message for issue in gate.errors))
|
|
299
|
+
"; ".join(issue.message for issue in gate.errors), deferred_review)
|
|
261
300
|
|
|
262
301
|
|
|
263
302
|
def run_done_gate(root: str, task_dir: str, cheap: bool = False, budget: Optional[float] = None, include_e2e: bool = False, task_id: str = "") -> DoneResult:
|