@michaelxwb/code-flow 0.6.4 → 0.8.1
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 +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -0,0 +1,1142 @@
|
|
|
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
|
+
merge 按 TASK-ID 顺序回并:worktree 内 rebase → 状态文件冲突按确定性并集规则
|
|
11
|
+
解决 → 主区 --no-ff 合并 → refresh 收敛;代码文件冲突保留人工(fail-closed)
|
|
12
|
+
cleanup 合并完成后移除 worktree;已合入的分支一并删除,未合入的保留可追溯
|
|
13
|
+
|
|
14
|
+
协议(详见 cf-task:start 命令正文):
|
|
15
|
+
- worktree: .code-flow/worktrees/<run-id>/<TASK-ID>/
|
|
16
|
+
- branch: cf-task/<slug>/<run-id>/<TASK-ID>
|
|
17
|
+
- 元数据: .code-flow/worktrees/<run-id>/run.json(base head / 任务清单)
|
|
18
|
+
|
|
19
|
+
stdout 始终为 JSON:{"ok": true, ...} 或 {"ok": false, "code": ..., "message": ...}。
|
|
20
|
+
预检失败时命令方回退串行,不得手工绕过本脚本建 worktree/分支。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import os
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
34
|
+
|
|
35
|
+
from codeflow.cli.cf_task_index import parse_task_file
|
|
36
|
+
from codeflow.cli.cf_task_index import task_batches
|
|
37
|
+
from codeflow.task.cf_task_merge import MergeConflictError
|
|
38
|
+
from codeflow.task.cf_task_merge import merge_context_text
|
|
39
|
+
from codeflow.task.cf_task_merge import merge_manifest_text
|
|
40
|
+
from codeflow.task.cf_task_merge import merge_task_markdown
|
|
41
|
+
from codeflow.task.cf_task_merge import stale_context_text
|
|
42
|
+
from codeflow.core.cf_task_state import FINISHED_STATUSES
|
|
43
|
+
from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids
|
|
44
|
+
from codeflow.evidence.cf_evidence_store import EvidenceStore
|
|
45
|
+
from codeflow.core.cf_exec_base import worktree_fingerprint
|
|
46
|
+
from codeflow.core import cf_git
|
|
47
|
+
from codeflow.core.cf_runtime_install import verify_install
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
WORKTREES_DIR = ".code-flow/worktrees"
|
|
51
|
+
MARKER = ".code-flow/.active-task.json"
|
|
52
|
+
GITIGNORE = ".code-flow/.gitignore"
|
|
53
|
+
_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
54
|
+
_TASK_ID = re.compile(r"^TASK-\d+$")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ParallelError(ValueError):
|
|
58
|
+
"""Precondition or protocol violation with a stable machine code."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, code: str, message: str,
|
|
61
|
+
details: Optional[Mapping[str, object]] = None) -> None:
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.code = code
|
|
64
|
+
self.message = message
|
|
65
|
+
self.details = dict(details) if details else {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ParallelEvidenceError(ParallelError):
|
|
69
|
+
"""Done-Gate evidence rejection carrying machine-readable missing checks."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
code: str,
|
|
74
|
+
message: str,
|
|
75
|
+
missing_checks: Sequence[Mapping[str, object]] = (),
|
|
76
|
+
required_checks: Sequence[str] = (),
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__(code, message)
|
|
79
|
+
self.missing_checks = tuple(dict(item) for item in missing_checks)
|
|
80
|
+
self.required_checks = tuple(required_checks)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_git(root: Path, arguments: Sequence[str]) -> str:
|
|
84
|
+
try:
|
|
85
|
+
return cf_git.run_git(str(root), arguments)
|
|
86
|
+
except cf_git.GitError as exc:
|
|
87
|
+
raise ParallelError(exc.code, exc.message) from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _assert_repo(root: Path) -> None:
|
|
91
|
+
try:
|
|
92
|
+
top = _run_git(root, ("rev-parse", "--show-toplevel")).strip()
|
|
93
|
+
except ParallelError as exc:
|
|
94
|
+
raise ParallelError("git_unavailable", "并行执行需要 git 仓库") from exc
|
|
95
|
+
if Path(top).resolve() != root:
|
|
96
|
+
raise ParallelError("not_project_root", f"root 必须是仓库根目录: {top}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _status_paths(root: Path) -> list[str]:
|
|
100
|
+
try:
|
|
101
|
+
return list(cf_git.changed_files(str(root)))
|
|
102
|
+
except cf_git.GitError as exc:
|
|
103
|
+
raise ParallelError(exc.code, exc.message) from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _require_ignored(root: Path) -> None:
|
|
107
|
+
ignore = root / GITIGNORE
|
|
108
|
+
try:
|
|
109
|
+
text = ignore.read_text(encoding="utf-8")
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
raise ParallelError("worktrees_not_ignored", f"缺少 {GITIGNORE}: {exc}") from exc
|
|
112
|
+
if "worktrees/" not in text.split():
|
|
113
|
+
raise ParallelError(
|
|
114
|
+
"worktrees_not_ignored", f"{GITIGNORE} 缺少 worktrees/ 规则,请先运行 code-flow init 升级"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _require_no_active(root: Path) -> None:
|
|
119
|
+
if (root / MARKER).exists():
|
|
120
|
+
raise ParallelError("active_exists", "主 worktree 已有 active TASK,先 finish/doctor 后再并行")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _commit_task_dir(root: Path, task_file: str) -> Optional[str]:
|
|
124
|
+
# Git porcelain emits forward-slash paths; compare in POSIX form so
|
|
125
|
+
# Windows backslash paths are not all classified as "outside".
|
|
126
|
+
task_dir = Path(task_file).parent.as_posix()
|
|
127
|
+
entries = _status_paths(root)
|
|
128
|
+
outside = [
|
|
129
|
+
path
|
|
130
|
+
for path in entries
|
|
131
|
+
if not (path == task_dir or path.startswith(f"{task_dir}/"))
|
|
132
|
+
]
|
|
133
|
+
if outside:
|
|
134
|
+
sample = ", ".join(outside[:8]) + (" ..." if len(outside) > 8 else "")
|
|
135
|
+
raise ParallelError(
|
|
136
|
+
"dirty_tree",
|
|
137
|
+
"并行要求 tracked 工作区干净(需求目录以外仍有改动: "
|
|
138
|
+
f"{sample})。请先提交(git add -A && git commit -m 'wip')"
|
|
139
|
+
"或暂存(git stash push -u)后重试;需求目录内的流程产物会自动提交。",
|
|
140
|
+
)
|
|
141
|
+
if not entries:
|
|
142
|
+
return None
|
|
143
|
+
_run_git(root, ("add", "-A", "--", str(task_dir)))
|
|
144
|
+
_run_git(root, ("commit", "-q", "-m", f"chore(cf-task): plan {Path(task_file).stem}"))
|
|
145
|
+
return _run_git(root, ("rev-parse", "HEAD")).strip()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_batch(root: Path, task_file: str, tasks: Sequence[str]) -> str:
|
|
149
|
+
path = root / task_file
|
|
150
|
+
if not path.is_file():
|
|
151
|
+
raise ParallelError("task_file_missing", f"任务文件不存在: {task_file}")
|
|
152
|
+
try:
|
|
153
|
+
nodes = parse_task_file(str(path))
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
raise ParallelError("task_parse_error", str(exc)) from exc
|
|
156
|
+
by_id = {node.task_id: node for node in nodes}
|
|
157
|
+
for task in tasks:
|
|
158
|
+
if task not in by_id:
|
|
159
|
+
raise ParallelError("unknown_task", f"{task} 不在任务文件中")
|
|
160
|
+
if by_id[task].status != "draft":
|
|
161
|
+
raise ParallelError("task_not_draft", f"{task} 当前 Status 为 {by_id[task].status},不可并行激活")
|
|
162
|
+
try:
|
|
163
|
+
ready = task_batches(nodes)[0]
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
raise ParallelError("task_graph_error", str(exc)) from exc
|
|
166
|
+
not_ready = [task for task in tasks if task not in ready]
|
|
167
|
+
if not_ready:
|
|
168
|
+
raise ParallelError("task_not_ready", f"依赖未完成,本批不可激活: {', '.join(not_ready)}(可激活: {', '.join(ready)})")
|
|
169
|
+
return str(path)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _branch_slug(task_file: str) -> str:
|
|
173
|
+
stem = Path(task_file).stem
|
|
174
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._")
|
|
175
|
+
return slug[:40] or "batch"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _worktree_paths(root: Path, run_id: str, task: str, task_file: str) -> tuple[Path, str]:
|
|
179
|
+
branch = f"cf-task/{_branch_slug(task_file)}/{run_id}/{task}"
|
|
180
|
+
return root / WORKTREES_DIR / run_id / task, branch
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _run_id() -> str:
|
|
184
|
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _unique_run_id(root: Path) -> str:
|
|
188
|
+
"""Second-precision run ids collide when two batches are dispatched in the
|
|
189
|
+
same second; that overwrote run.json and orphaned the first run's worktrees.
|
|
190
|
+
Suffix -2, -3, … until the run directory is free."""
|
|
191
|
+
base = _run_id()
|
|
192
|
+
identifier = base
|
|
193
|
+
counter = 1
|
|
194
|
+
while (root / WORKTREES_DIR / identifier).exists() or not _RUN_ID.match(identifier):
|
|
195
|
+
counter += 1
|
|
196
|
+
identifier = f"{base}-{counter}"
|
|
197
|
+
return identifier
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _write_run_meta(run_dir: Path, payload: dict[str, object]) -> None:
|
|
201
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
meta = run_dir / "run.json"
|
|
203
|
+
if meta.exists():
|
|
204
|
+
raise ParallelError("run_id_conflict", f"run 记录已存在: {meta}")
|
|
205
|
+
temporary = meta.with_suffix(".json.tmp")
|
|
206
|
+
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
207
|
+
temporary.replace(meta)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_run_meta(root: Path, run_id: str) -> dict[str, object]:
|
|
211
|
+
meta = root / WORKTREES_DIR / run_id / "run.json"
|
|
212
|
+
try:
|
|
213
|
+
data = json.loads(meta.read_text(encoding="utf-8"))
|
|
214
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
215
|
+
raise ParallelError("run_not_found", f"找不到并行记录 {meta}: {exc}") from exc
|
|
216
|
+
if not isinstance(data, dict) or not isinstance(data.get("base_head"), str) or not isinstance(data.get("task_file"), str):
|
|
217
|
+
raise ParallelError("run_corrupt", f"并行记录损坏(缺 base_head/task_file): {meta}")
|
|
218
|
+
return data
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _update_run_meta(root: Path, run_id: str, updates: Mapping[str, object]) -> None:
|
|
222
|
+
"""Persist batch-level state (smoke status) without rewriting worktrees."""
|
|
223
|
+
data = _read_run_meta(root, run_id)
|
|
224
|
+
data.update(updates)
|
|
225
|
+
path = root / WORKTREES_DIR / run_id / "run.json"
|
|
226
|
+
temporary = path.with_suffix(".json.tmp")
|
|
227
|
+
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
228
|
+
temporary.replace(path)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _install_worktree_manifest(root: Path, worktree: Path) -> None:
|
|
232
|
+
source = root / ".code-flow/.runtime-install.json"
|
|
233
|
+
if not source.is_file():
|
|
234
|
+
return # Core-only repositories have no installed platform runtime.
|
|
235
|
+
try:
|
|
236
|
+
verify_install(str(root))
|
|
237
|
+
# Platform files such as .claude/settings.local.json are commonly
|
|
238
|
+
# gitignored, so a fresh worktree does not receive them from checkout.
|
|
239
|
+
# Copy every managed artifact that is missing before verifying.
|
|
240
|
+
manifest = json.loads(source.read_text(encoding="utf-8"))
|
|
241
|
+
managed = list(manifest.get("files", {})) + list(manifest.get("hook_contracts", {}))
|
|
242
|
+
for relative in managed:
|
|
243
|
+
if not isinstance(relative, str) or not relative:
|
|
244
|
+
continue
|
|
245
|
+
target_file = worktree / relative
|
|
246
|
+
if target_file.exists():
|
|
247
|
+
continue
|
|
248
|
+
source_file = root / relative
|
|
249
|
+
if not source_file.is_file():
|
|
250
|
+
continue
|
|
251
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
target_file.write_bytes(source_file.read_bytes())
|
|
253
|
+
target = worktree / ".code-flow/.runtime-install.json"
|
|
254
|
+
target.write_bytes(source.read_bytes())
|
|
255
|
+
verify_install(str(worktree))
|
|
256
|
+
except (OSError, ValueError) as exc:
|
|
257
|
+
raise ParallelError("worktree_runtime_invalid", f"worktree 运行时安装校验失败: {exc}") from exc
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _create_worktrees(root: Path, task_file: str, tasks: Sequence[str], run_id: str) -> dict[str, object]:
|
|
261
|
+
created: list[tuple[Path, str]] = []
|
|
262
|
+
base_head = _run_git(root, ("rev-parse", "HEAD")).strip()
|
|
263
|
+
try:
|
|
264
|
+
for task in tasks:
|
|
265
|
+
path, branch = _worktree_paths(root, run_id, task, task_file)
|
|
266
|
+
if path.exists() or _branch_exists(root, branch):
|
|
267
|
+
raise ParallelError("worktree_exists", f"{task} 的 worktree 或分支已存在: {branch}")
|
|
268
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
_run_git(root, ("worktree", "add", "-b", branch, str(path), "HEAD"))
|
|
270
|
+
created.append((path, branch))
|
|
271
|
+
if not (path / task_file).is_file():
|
|
272
|
+
raise ParallelError("task_file_missing", f"{task}: worktree 内缺少 {task_file}(需先提交任务文件)")
|
|
273
|
+
_install_worktree_manifest(root, path)
|
|
274
|
+
except ParallelError:
|
|
275
|
+
for path, branch in created:
|
|
276
|
+
_remove_worktree(root, path, force=True)
|
|
277
|
+
_run_git(root, ("branch", "-D", branch))
|
|
278
|
+
raise
|
|
279
|
+
return {"base_head": base_head, "created": created}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _branch_exists(root: Path, branch: str) -> bool:
|
|
283
|
+
result = subprocess.run(
|
|
284
|
+
("git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"),
|
|
285
|
+
cwd=str(root), capture_output=True, check=False,
|
|
286
|
+
)
|
|
287
|
+
return result.returncode == 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _delete_branch(root: Path, branch: str, force: bool = False) -> bool:
|
|
291
|
+
# 默认 -d 只删已合入分支;--force 表示用户明确接受未合入分支的删除。
|
|
292
|
+
flag = "-D" if force else "-d"
|
|
293
|
+
result = subprocess.run(
|
|
294
|
+
("git", "branch", flag, branch), cwd=str(root), capture_output=True, check=False,
|
|
295
|
+
)
|
|
296
|
+
return result.returncode == 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _remove_worktree(root: Path, path: Path, force: bool) -> None:
|
|
300
|
+
arguments = ["worktree", "remove"]
|
|
301
|
+
if force:
|
|
302
|
+
arguments.append("--force")
|
|
303
|
+
arguments.append(str(path))
|
|
304
|
+
_run_git(root, tuple(arguments))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _relative_task_file(root: Path, task_file: str) -> str:
|
|
308
|
+
candidate = Path(task_file)
|
|
309
|
+
absolute = candidate if candidate.is_absolute() else root / candidate
|
|
310
|
+
try:
|
|
311
|
+
return absolute.resolve().relative_to(root).as_posix()
|
|
312
|
+
except ValueError as exc:
|
|
313
|
+
raise ParallelError("task_file_outside_root", f"任务文件不在仓库内: {task_file}") from exc
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def prepare(root: Path, task_file: str, tasks: Sequence[str], run_id: Optional[str]) -> dict[str, object]:
|
|
317
|
+
root = root.resolve()
|
|
318
|
+
_assert_repo(root)
|
|
319
|
+
task_file = _relative_task_file(root, task_file)
|
|
320
|
+
_require_ignored(root)
|
|
321
|
+
_require_no_active(root)
|
|
322
|
+
identifier = run_id or _unique_run_id(root)
|
|
323
|
+
if not _RUN_ID.match(identifier):
|
|
324
|
+
raise ParallelError("invalid_run_id", f"run-id 非法: {identifier}")
|
|
325
|
+
if (root / WORKTREES_DIR / identifier / "run.json").exists():
|
|
326
|
+
raise ParallelError(
|
|
327
|
+
"run_id_conflict",
|
|
328
|
+
f"run 记录已存在: {identifier}。请清理后重试(cleanup)或使用新的 run-id。",
|
|
329
|
+
)
|
|
330
|
+
if not tasks:
|
|
331
|
+
raise ParallelError("no_tasks", "未指定并行 TASK")
|
|
332
|
+
if len(set(tasks)) != len(tasks):
|
|
333
|
+
raise ParallelError("duplicate_task", "并行 TASK 列表存在重复")
|
|
334
|
+
for task in tasks:
|
|
335
|
+
if not _TASK_ID.match(task):
|
|
336
|
+
raise ParallelError("invalid_task_id", f"TASK-ID 非法: {task}")
|
|
337
|
+
_validate_batch(root, task_file, tasks)
|
|
338
|
+
_commit_task_dir(root, task_file)
|
|
339
|
+
try:
|
|
340
|
+
outcome = _create_worktrees(root, task_file, tasks, identifier)
|
|
341
|
+
except Exception:
|
|
342
|
+
try:
|
|
343
|
+
_run_git(root, ("worktree", "prune"))
|
|
344
|
+
except ParallelError:
|
|
345
|
+
pass
|
|
346
|
+
raise
|
|
347
|
+
worktrees = [
|
|
348
|
+
{"task": task, "branch": branch, "path": str(path)}
|
|
349
|
+
for task, (path, branch) in zip(tasks, outcome["created"])
|
|
350
|
+
]
|
|
351
|
+
_write_run_meta(
|
|
352
|
+
root / WORKTREES_DIR / identifier,
|
|
353
|
+
{"version": 1, "run_id": identifier, "task_file": task_file,
|
|
354
|
+
"base_head": outcome["base_head"], "worktrees": worktrees},
|
|
355
|
+
)
|
|
356
|
+
return {"ok": True, "action": "prepare", "run_id": identifier, "task_file": task_file,
|
|
357
|
+
"base_head": outcome["base_head"], "worktrees": worktrees}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _required_check_map(manifest: Mapping[str, object], task: str) -> dict[str, list[str]]:
|
|
361
|
+
"""Required code checks: functional scenarios traceable to this TASK.
|
|
362
|
+
|
|
363
|
+
``check`` is the schema3 execution identity. Schema2 manifests have no
|
|
364
|
+
scenario→check link, so evidence validation is skipped for them and the
|
|
365
|
+
status gate remains authoritative.
|
|
366
|
+
"""
|
|
367
|
+
scenarios = manifest.get("scenarios")
|
|
368
|
+
if not isinstance(scenarios, list):
|
|
369
|
+
return {}
|
|
370
|
+
required: dict[str, list[str]] = {}
|
|
371
|
+
for item in scenarios:
|
|
372
|
+
if not isinstance(item, Mapping):
|
|
373
|
+
continue
|
|
374
|
+
if str(item.get("kind") or "functional") in ("manual", "e2e"):
|
|
375
|
+
continue
|
|
376
|
+
owner = item.get("owner")
|
|
377
|
+
if isinstance(owner, str) and owner and owner != task:
|
|
378
|
+
continue
|
|
379
|
+
scenario_id = item.get("id")
|
|
380
|
+
if not isinstance(scenario_id, str) or not scenario_id:
|
|
381
|
+
continue
|
|
382
|
+
check_ids = scenario_check_ids(item)
|
|
383
|
+
if not check_ids:
|
|
384
|
+
# schema2 has no check link: the scenario id is the evidence key.
|
|
385
|
+
check_ids = [scenario_id]
|
|
386
|
+
for check_id in check_ids:
|
|
387
|
+
required.setdefault(check_id, []).append(scenario_id)
|
|
388
|
+
return required
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _requires_acceptance(task_file: str, task: str) -> bool:
|
|
392
|
+
"""True when the TASK owns Spec rules or acceptance scenarios."""
|
|
393
|
+
try:
|
|
394
|
+
text = Path(task_file).read_text(encoding="utf-8")
|
|
395
|
+
except (OSError, UnicodeError):
|
|
396
|
+
return False
|
|
397
|
+
match = re.search(rf"(?ms)^##\s+{re.escape(task)}:.*?(?=^##\s+TASK-|\Z)", text)
|
|
398
|
+
if match is None:
|
|
399
|
+
return False
|
|
400
|
+
body = match.group(0)
|
|
401
|
+
for field in ("Spec-Refs", "Acceptance-Refs"):
|
|
402
|
+
refs = re.search(rf"(?m)^- \*\*{field}\*\*:[ \t]*([^\n]*)", body)
|
|
403
|
+
if refs and refs.group(1).strip().upper() not in ("", "N/A"):
|
|
404
|
+
return True
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _task_manifest(worktree: Path, task_file: str) -> Optional[dict[str, object]]:
|
|
409
|
+
path = worktree / Path(task_file).parent / ".acceptance-manifest.json"
|
|
410
|
+
if not path.is_file():
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
return load_manifest(path)
|
|
414
|
+
except (OSError, ValueError) as exc:
|
|
415
|
+
raise ParallelError("acceptance_manifest_invalid", f"acceptance manifest 无效: {exc}") from exc
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _run_matches(run: Mapping[str, object], expected_hash: str,
|
|
419
|
+
check: Optional[Mapping[str, object]] = None,
|
|
420
|
+
scenarios: Sequence[Mapping[str, object]] = ()) -> bool:
|
|
421
|
+
"""A central evidence run proves this check against this worktree content."""
|
|
422
|
+
run_id = run.get("run_id")
|
|
423
|
+
status = run.get("status")
|
|
424
|
+
if status == "accepted_risk" and not _live_accepted_risk(check, scenarios):
|
|
425
|
+
return False
|
|
426
|
+
return (
|
|
427
|
+
isinstance(run_id, str)
|
|
428
|
+
and bool(run_id.strip())
|
|
429
|
+
and run.get("input_hash") == expected_hash
|
|
430
|
+
and (status == "accepted_risk" or run.get("exit_code") == 0)
|
|
431
|
+
and status in ("passed", "verified", "accepted_risk")
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _live_accepted_risk(check: Optional[Mapping[str, object]],
|
|
436
|
+
scenarios: Sequence[Mapping[str, object]]) -> bool:
|
|
437
|
+
from codeflow.acceptance.cf_acceptance_risk import accepted_risk
|
|
438
|
+
return bool(scenarios) and all(accepted_risk(check, row) is not None for row in scenarios)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _evidence_reason(runs: Sequence[Mapping[str, object]], expected_hash: str) -> str:
|
|
442
|
+
if not runs:
|
|
443
|
+
return "not_executed"
|
|
444
|
+
passed = [
|
|
445
|
+
run for run in runs
|
|
446
|
+
if run.get("exit_code") == 0 and run.get("status") in ("passed", "verified")
|
|
447
|
+
]
|
|
448
|
+
if not passed:
|
|
449
|
+
return "nonzero_exit"
|
|
450
|
+
if any(run.get("input_hash") == expected_hash for run in passed):
|
|
451
|
+
return "missing_run_id"
|
|
452
|
+
return "input_hash_mismatch"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _missing_evidence(
|
|
456
|
+
worktree: Path, task: str, manifest: Mapping[str, object], task_file: str
|
|
457
|
+
) -> tuple[list[dict[str, object]], list[str]]:
|
|
458
|
+
"""Return checks without matching central evidence plus all required IDs."""
|
|
459
|
+
required = _required_check_map(manifest, task)
|
|
460
|
+
if not required:
|
|
461
|
+
return [], []
|
|
462
|
+
expected = worktree_fingerprint(str(worktree))
|
|
463
|
+
if not expected:
|
|
464
|
+
raise ParallelError(
|
|
465
|
+
"evidence_hash_unavailable",
|
|
466
|
+
f"{task}: 无法计算 worktree input_hash,拒绝用无法校验的旧证据放行",
|
|
467
|
+
)
|
|
468
|
+
from codeflow.acceptance.cf_check_inputs import check_input_hash
|
|
469
|
+
checks = {
|
|
470
|
+
str(item.get("id")): item
|
|
471
|
+
for item in (manifest.get("checks") if isinstance(manifest.get("checks"), list) else [])
|
|
472
|
+
if isinstance(item, Mapping) and item.get("id")
|
|
473
|
+
}
|
|
474
|
+
from codeflow.evidence.cf_evidence_store import requirement_key
|
|
475
|
+
store = EvidenceStore(worktree, requirement=requirement_key(Path(task_file).parent))
|
|
476
|
+
missing: list[dict[str, object]] = []
|
|
477
|
+
for check_id in sorted(required):
|
|
478
|
+
check = checks.get(check_id)
|
|
479
|
+
check_hash = (
|
|
480
|
+
str(check_input_hash(str(worktree), check, expected).get("input_hash") or expected)
|
|
481
|
+
if check is not None else expected
|
|
482
|
+
)
|
|
483
|
+
runs = store.list_runs(check_id)
|
|
484
|
+
rows = [row for row in manifest.get("scenarios", [])
|
|
485
|
+
if isinstance(row, Mapping) and row.get("id") in required[check_id]]
|
|
486
|
+
if not any(_run_matches(run, check_hash, check, rows) for run in runs):
|
|
487
|
+
missing.append({
|
|
488
|
+
"check_id": check_id,
|
|
489
|
+
"scenarios": sorted(required[check_id]),
|
|
490
|
+
"reason": _evidence_reason(runs, check_hash),
|
|
491
|
+
"expected_input_hash": check_hash,
|
|
492
|
+
})
|
|
493
|
+
return missing, sorted(required)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _evidence_state(
|
|
497
|
+
worktree: Path, task: str, task_file: str
|
|
498
|
+
) -> tuple[list[dict[str, object]], list[str]]:
|
|
499
|
+
manifest = _task_manifest(worktree, task_file)
|
|
500
|
+
if manifest is None:
|
|
501
|
+
if _requires_acceptance(str(worktree / task_file), task):
|
|
502
|
+
raise ParallelEvidenceError(
|
|
503
|
+
"required_evidence_missing",
|
|
504
|
+
f"{task}: 有 Spec-Refs 但缺少 .acceptance-manifest.json;先锁定验收计划并执行 required 检查",
|
|
505
|
+
[{"check_id": "manifest", "scenarios": [], "reason": "manifest_missing"}],
|
|
506
|
+
["manifest"],
|
|
507
|
+
)
|
|
508
|
+
return [], []
|
|
509
|
+
return _missing_evidence(worktree, task, manifest, task_file)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _evidence_summary(missing: Sequence[Mapping[str, object]]) -> str:
|
|
513
|
+
return ", ".join(
|
|
514
|
+
f"{item.get('check_id')}({','.join(str(s) for s in item.get('scenarios', []))}: {item.get('reason')})"
|
|
515
|
+
for item in missing
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _reject_unfinished(task: str, status: str, path: Path, task_file: str) -> None:
|
|
520
|
+
missing, required = _evidence_state(path, task, task_file)
|
|
521
|
+
detail = _evidence_summary(missing)
|
|
522
|
+
suffix = f";先补 required 检查: {detail}" if detail else ";先补 required 检查后重新 finish"
|
|
523
|
+
raise ParallelEvidenceError(
|
|
524
|
+
f"task_{status}",
|
|
525
|
+
f"{task}: Status 为 {status},未通过 Done Gate{suffix}",
|
|
526
|
+
missing,
|
|
527
|
+
required,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _require_evidence(task: str, path: Path, task_file: str, files: Sequence[str] = ()) -> list[str]:
|
|
532
|
+
context_path = path / Path(task_file).parent / "spec-context.yml"
|
|
533
|
+
if context_path.is_file():
|
|
534
|
+
from codeflow.cli.cf_spec_context import load_context
|
|
535
|
+
from codeflow.spec.cf_workflow_service import task_truth_issues
|
|
536
|
+
from codeflow.task.cf_task_runtime import task_spec_scope
|
|
537
|
+
context = load_context(str(context_path))
|
|
538
|
+
issues = task_truth_issues(str(path / task_file), task, context)
|
|
539
|
+
if issues:
|
|
540
|
+
try:
|
|
541
|
+
scope = task_spec_scope(str(path), str(context_path.parent), task, tuple(files))
|
|
542
|
+
except ValueError as exc:
|
|
543
|
+
raise ParallelError("task_scope_invalid", f"{task}: {exc}") from exc
|
|
544
|
+
issues = task_truth_issues(str(path / task_file), task, context, spec_ids=scope)
|
|
545
|
+
if issues:
|
|
546
|
+
raise ParallelEvidenceError(
|
|
547
|
+
"task_status_truth_mismatch",
|
|
548
|
+
f"{task}: 文档状态与 spec-context 冲突: {', '.join(issues)}",
|
|
549
|
+
[{"check_id": issue.split(":", 1)[0], "scenarios": [], "reason": "unverified_rule"} for issue in issues],
|
|
550
|
+
list(issues),
|
|
551
|
+
)
|
|
552
|
+
missing, required = _evidence_state(path, task, task_file)
|
|
553
|
+
if missing:
|
|
554
|
+
raise ParallelEvidenceError(
|
|
555
|
+
"required_evidence_missing",
|
|
556
|
+
f"{task}: required code checks 缺少匹配 input_hash 的 central evidence: "
|
|
557
|
+
f"{_evidence_summary(missing)};先补 required 检查(在 worktree 内重跑 task finish)",
|
|
558
|
+
missing,
|
|
559
|
+
required,
|
|
560
|
+
)
|
|
561
|
+
return required
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _collect_one(root: Path, entry: dict[str, object], base_head: str, task_file: str, commit: bool = True) -> dict[str, object]:
|
|
565
|
+
task = str(entry["task"])
|
|
566
|
+
path = Path(str(entry["path"]))
|
|
567
|
+
branch = str(entry["branch"])
|
|
568
|
+
if not path.is_dir():
|
|
569
|
+
raise ParallelError("worktree_missing", f"{task}: worktree 不存在: {path}")
|
|
570
|
+
try:
|
|
571
|
+
nodes = parse_task_file(str(path / task_file))
|
|
572
|
+
except ValueError as exc:
|
|
573
|
+
raise ParallelError("task_parse_error", f"{task}: {exc}") from exc
|
|
574
|
+
status = next((node.status for node in nodes if node.task_id == task), None)
|
|
575
|
+
if status in ("provisional", "blocked"):
|
|
576
|
+
_reject_unfinished(task, status, path, task_file)
|
|
577
|
+
if status not in FINISHED_STATUSES:
|
|
578
|
+
raise ParallelError("task_not_finished", f"{task}: Status 为 {status},未通过 Done Gate")
|
|
579
|
+
if (path / MARKER).exists():
|
|
580
|
+
raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
|
|
581
|
+
dirty = _status_paths(path)
|
|
582
|
+
if dirty:
|
|
583
|
+
outside = [item for item in dirty if not item.startswith(".code-flow/")]
|
|
584
|
+
if outside:
|
|
585
|
+
raise ParallelError(
|
|
586
|
+
"worktree_dirty",
|
|
587
|
+
f"{task}: .code-flow 之外存在未提交改动: {', '.join(outside[:8])};"
|
|
588
|
+
"collect 只允许自动提交 finish 的流程回写",
|
|
589
|
+
)
|
|
590
|
+
# finish 会在通过后回写 Evidence/状态;只提交受管流程目录,绝不 add -A。
|
|
591
|
+
if not commit:
|
|
592
|
+
raise ParallelError(
|
|
593
|
+
"worktree_dirty",
|
|
594
|
+
f"{task}: 尚有未提交的流程回写: {', '.join(dirty[:8])};去掉 --no-commit 或手工提交",
|
|
595
|
+
)
|
|
596
|
+
_run_git(path, ("add", "-A", "--", ".code-flow"))
|
|
597
|
+
_run_git(path, ("commit", "-q", "-m", f"cf-task({task}): finish 回写验收证据与任务状态"))
|
|
598
|
+
head = _run_git(path, ("rev-parse", "HEAD")).strip()
|
|
599
|
+
if head == base_head:
|
|
600
|
+
raise ParallelError("no_commits", f"{task}: worktree 无新提交")
|
|
601
|
+
files = _run_git(root, ("diff", "--name-only", f"{base_head}..{branch}")).splitlines()
|
|
602
|
+
required = _require_evidence(task, path, task_file, files)
|
|
603
|
+
return {"task": task, "branch": branch, "path": str(path), "head": head,
|
|
604
|
+
"status": status, "files": sorted(item for item in files if item),
|
|
605
|
+
"evidence": {"required_checks": required, "verified_checks": required}}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def collect(root: Path, run_id: str, tasks: Optional[Sequence[str]], commit: bool = True) -> dict[str, object]:
|
|
609
|
+
root = root.resolve()
|
|
610
|
+
meta = _read_run_meta(root, run_id)
|
|
611
|
+
entries = [item for item in meta.get("worktrees", []) if isinstance(item, dict)]
|
|
612
|
+
if tasks:
|
|
613
|
+
wanted = set(tasks)
|
|
614
|
+
entries = [item for item in entries if item.get("task") in wanted]
|
|
615
|
+
results: list[dict[str, object]] = []
|
|
616
|
+
for entry in entries:
|
|
617
|
+
try:
|
|
618
|
+
results.append(_collect_one(root, entry, str(meta["base_head"]), str(meta["task_file"]), commit))
|
|
619
|
+
except ParallelEvidenceError as exc:
|
|
620
|
+
results.append({
|
|
621
|
+
"task": str(entry.get("task", "")),
|
|
622
|
+
"ok": False,
|
|
623
|
+
"code": exc.code,
|
|
624
|
+
"message": exc.message,
|
|
625
|
+
"missing_checks": list(exc.missing_checks),
|
|
626
|
+
})
|
|
627
|
+
except ParallelError as exc:
|
|
628
|
+
results.append({"task": str(entry.get("task", "")), "ok": False, "code": exc.code, "message": exc.message})
|
|
629
|
+
ok = bool(results) and all(item.get("code") is None for item in results)
|
|
630
|
+
payload: dict[str, object] = {"ok": ok, "action": "collect", "run_id": run_id, "results": results}
|
|
631
|
+
if ok:
|
|
632
|
+
# Batch-level smoke is executed once after merge (TASK-011); collect
|
|
633
|
+
# only records that the batch still owes it.
|
|
634
|
+
payload["batch_smoke"] = "pending"
|
|
635
|
+
_update_run_meta(root, run_id, {
|
|
636
|
+
"batch_smoke": {"status": "pending", "profile": "smoke"},
|
|
637
|
+
})
|
|
638
|
+
return payload
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# --- merge: rebase + deterministic state-file resolution + --no-ff back-merge ---
|
|
642
|
+
|
|
643
|
+
_MAX_CONFLICT_ROUNDS = 8
|
|
644
|
+
_STATE_MERGE_NAMES = ("spec-context.yml", ".acceptance-manifest.json")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _git_try(root: Path, arguments: Sequence[str]) -> subprocess.CompletedProcess:
|
|
648
|
+
return subprocess.run(
|
|
649
|
+
("git", *arguments), cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _output_tail(result: subprocess.CompletedProcess) -> str:
|
|
654
|
+
text = (result.stderr or result.stdout or "").strip()
|
|
655
|
+
return text[-300:]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _abort_git(root: Path, *arguments: str) -> None:
|
|
659
|
+
subprocess.run(("git", *arguments), cwd=str(root), capture_output=True, check=False)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _unmerged_files(root: Path) -> list[str]:
|
|
663
|
+
result = _git_try(root, ("diff", "--name-only", "--diff-filter=U"))
|
|
664
|
+
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _git_show(root: Path, stage: int, relative: str) -> str:
|
|
668
|
+
result = _git_try(root, ("show", f":{stage}:{relative}"))
|
|
669
|
+
if result.returncode != 0:
|
|
670
|
+
raise ParallelError("conflict_stage_missing", f"{relative}: 缺少冲突 stage {stage}")
|
|
671
|
+
return result.stdout
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _is_state_file(relative: str, task_file: str) -> bool:
|
|
675
|
+
name = Path(relative).name
|
|
676
|
+
if name in _STATE_MERGE_NAMES:
|
|
677
|
+
return True
|
|
678
|
+
parent = Path(task_file).parent.as_posix()
|
|
679
|
+
return name.endswith(".md") and Path(relative).parent.as_posix() == parent
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _resolve_state_file(workdir: Path, relative: str, task: str) -> None:
|
|
683
|
+
ours = _git_show(workdir, 2, relative)
|
|
684
|
+
theirs = _git_show(workdir, 3, relative)
|
|
685
|
+
name = Path(relative).name
|
|
686
|
+
try:
|
|
687
|
+
if name == "spec-context.yml":
|
|
688
|
+
merged = merge_context_text(ours, theirs)
|
|
689
|
+
elif name == ".acceptance-manifest.json":
|
|
690
|
+
merged = merge_manifest_text(ours, theirs)
|
|
691
|
+
elif "## TASK-" in ours or "## TASK-" in theirs:
|
|
692
|
+
merged = merge_task_markdown(ours, theirs, task)
|
|
693
|
+
else:
|
|
694
|
+
raise ParallelError("state_file_unresolvable", f"{relative}: 非任务状态文件,需人工解决")
|
|
695
|
+
except MergeConflictError as exc:
|
|
696
|
+
raise ParallelError(
|
|
697
|
+
exc.code,
|
|
698
|
+
f"{relative}: {exc}",
|
|
699
|
+
{"conflicts": list(exc.conflicts)},
|
|
700
|
+
) from exc
|
|
701
|
+
except ValueError as exc:
|
|
702
|
+
raise ParallelError("state_file_parse_error", f"{relative}: {exc}") from exc
|
|
703
|
+
(workdir / relative).write_text(merged, encoding="utf-8")
|
|
704
|
+
_run_git(workdir, ("add", "--", relative))
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _resolve_conflicts(workdir: Path, task: str, task_file: str, resolved: list[str]) -> None:
|
|
708
|
+
unresolved = _unmerged_files(workdir)
|
|
709
|
+
if not unresolved:
|
|
710
|
+
raise ParallelError("conflict_state_lost", "冲突状态已丢失,无法自动解决")
|
|
711
|
+
for relative in unresolved:
|
|
712
|
+
if not _is_state_file(relative, task_file):
|
|
713
|
+
raise ParallelError(
|
|
714
|
+
"code_conflict",
|
|
715
|
+
f"代码文件冲突需人工解决(状态文件已按并集规则自动处理): {', '.join(unresolved)}",
|
|
716
|
+
)
|
|
717
|
+
_resolve_state_file(workdir, relative, task)
|
|
718
|
+
if relative not in resolved:
|
|
719
|
+
resolved.append(relative)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _rebase_onto_main(path: Path, main_branch: str, task: str, task_file: str, resolved: list[str]) -> None:
|
|
723
|
+
result = _git_try(path, ("rebase", main_branch))
|
|
724
|
+
rounds = 0
|
|
725
|
+
while result.returncode != 0 and rounds < _MAX_CONFLICT_ROUNDS:
|
|
726
|
+
if not _unmerged_files(path):
|
|
727
|
+
_abort_git(path, "rebase", "--abort")
|
|
728
|
+
raise ParallelError("rebase_failed", f"{task}: git rebase 失败: {_output_tail(result)}")
|
|
729
|
+
try:
|
|
730
|
+
_resolve_conflicts(path, task, task_file, resolved)
|
|
731
|
+
except ParallelError:
|
|
732
|
+
_abort_git(path, "rebase", "--abort")
|
|
733
|
+
raise
|
|
734
|
+
result = _git_try(path, ("-c", "core.editor=true", "rebase", "--continue"))
|
|
735
|
+
if result.returncode != 0:
|
|
736
|
+
combined = (result.stdout or "") + (result.stderr or "")
|
|
737
|
+
if "rebase --skip" in combined or "is now empty" in combined:
|
|
738
|
+
result = _git_try(path, ("rebase", "--skip"))
|
|
739
|
+
rounds += 1
|
|
740
|
+
if result.returncode != 0:
|
|
741
|
+
_abort_git(path, "rebase", "--abort")
|
|
742
|
+
raise ParallelError("rebase_failed", f"{task}: git rebase 失败: {_output_tail(result)}")
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _merge_branch(root: Path, branch: str, task: str, task_file: str, resolved: list[str]) -> None:
|
|
746
|
+
result = _git_try(root, ("merge", "--no-ff", "-m", f"merge cf-task {task}", branch))
|
|
747
|
+
rounds = 0
|
|
748
|
+
while result.returncode != 0 and rounds < _MAX_CONFLICT_ROUNDS:
|
|
749
|
+
if not _unmerged_files(root):
|
|
750
|
+
_abort_git(root, "merge", "--abort")
|
|
751
|
+
raise ParallelError("merge_failed", f"{task}: git merge 失败: {_output_tail(result)}")
|
|
752
|
+
try:
|
|
753
|
+
_resolve_conflicts(root, task, task_file, resolved)
|
|
754
|
+
except ParallelError:
|
|
755
|
+
_abort_git(root, "merge", "--abort")
|
|
756
|
+
raise
|
|
757
|
+
result = _git_try(root, ("commit", "--no-edit"))
|
|
758
|
+
rounds += 1
|
|
759
|
+
if result.returncode != 0:
|
|
760
|
+
_abort_git(root, "merge", "--abort")
|
|
761
|
+
raise ParallelError("merge_failed", f"{task}: git merge 失败: {_output_tail(result)}")
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _branch_is_ancestor(root: Path, ancestor: str) -> bool:
|
|
765
|
+
return _git_try(root, ("merge-base", "--is-ancestor", ancestor, "HEAD")).returncode == 0
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _merge_one(root: Path, main_branch: str, entry: dict, task_file: str) -> dict[str, object]:
|
|
769
|
+
task = str(entry.get("task", ""))
|
|
770
|
+
path = Path(str(entry.get("path", "")))
|
|
771
|
+
branch = str(entry.get("branch", ""))
|
|
772
|
+
if not path.is_dir():
|
|
773
|
+
raise ParallelError("worktree_missing", f"{task}: worktree 不存在: {path}")
|
|
774
|
+
if (path / MARKER).exists():
|
|
775
|
+
raise ParallelError("active_marker_present", f"{task}: marker 未清理,finish 未完成")
|
|
776
|
+
try:
|
|
777
|
+
nodes = parse_task_file(str(path / task_file))
|
|
778
|
+
except ValueError as exc:
|
|
779
|
+
raise ParallelError("task_parse_error", f"{task}: {exc}") from exc
|
|
780
|
+
status = next((node.status for node in nodes if node.task_id == task), None)
|
|
781
|
+
if status not in FINISHED_STATUSES:
|
|
782
|
+
raise ParallelError("task_not_finished", f"{task}: Status 为 {status},未通过 Done Gate")
|
|
783
|
+
main_manifest = _task_manifest(root, task_file)
|
|
784
|
+
worktree_manifest = _task_manifest(path, task_file)
|
|
785
|
+
if main_manifest is not None and worktree_manifest is not None:
|
|
786
|
+
from codeflow.task.cf_task_merge import compare_manifest_checks
|
|
787
|
+
conflicts = compare_manifest_checks(main_manifest, worktree_manifest)
|
|
788
|
+
if conflicts:
|
|
789
|
+
detail = ", ".join(f"{item['check_id']}({','.join(item['fields'])})" for item in conflicts)
|
|
790
|
+
raise ParallelError("check_conflict", f"{task}: check 配置冲突: {detail}",
|
|
791
|
+
{"conflicts": conflicts})
|
|
792
|
+
changed = _run_git(path, ("diff", "--name-only", f"{main_branch}...HEAD")).splitlines()
|
|
793
|
+
_require_evidence(task, path, task_file, changed)
|
|
794
|
+
if _status_paths(path):
|
|
795
|
+
raise ParallelError("worktree_dirty", f"{task}: worktree 有未提交改动,先运行 collect")
|
|
796
|
+
if _branch_is_ancestor(root, branch):
|
|
797
|
+
return {"task": task, "ok": True, "status": "already_merged"}
|
|
798
|
+
resolved: list[str] = []
|
|
799
|
+
_rebase_onto_main(path, main_branch, task, task_file, resolved)
|
|
800
|
+
_merge_branch(root, branch, task, task_file, resolved)
|
|
801
|
+
return {
|
|
802
|
+
"task": task, "ok": True, "status": "merged", "resolved": sorted(set(resolved)),
|
|
803
|
+
"commit": _run_git(root, ("rev-parse", "HEAD")).strip(),
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _context_files(root: Path, task_file: str) -> list[Path]:
|
|
808
|
+
demand = root / Path(task_file).parent
|
|
809
|
+
return sorted(demand.rglob("spec-context.yml")) if demand.is_dir() else []
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _write_text_atomic(path: Path, text: str) -> None:
|
|
813
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
814
|
+
temporary.write_text(text, encoding="utf-8")
|
|
815
|
+
temporary.replace(path)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _stale_after_merge(root: Path, task_file: str, input_hash: str) -> list[dict[str, object]]:
|
|
819
|
+
"""Downgrade verified rules whose merge-time input hash changed.
|
|
820
|
+
|
|
821
|
+
The old evidence stays in place for audit; only the rule state becomes
|
|
822
|
+
stale so the next changed-profile run re-executes it instead of reusing a
|
|
823
|
+
green result from the pre-merge tree.
|
|
824
|
+
"""
|
|
825
|
+
changed: list[dict[str, object]] = []
|
|
826
|
+
for path in _context_files(root, task_file):
|
|
827
|
+
try:
|
|
828
|
+
text = path.read_text(encoding="utf-8")
|
|
829
|
+
merged, stale = stale_context_text(text, input_hash)
|
|
830
|
+
except (OSError, ValueError) as exc:
|
|
831
|
+
raise ParallelError("stale_context_failed", f"{path}: {exc}") from exc
|
|
832
|
+
if not stale:
|
|
833
|
+
continue
|
|
834
|
+
_write_text_atomic(path, merged)
|
|
835
|
+
changed.append({"path": path.relative_to(root).as_posix(), "rules": stale})
|
|
836
|
+
manifest = root / Path(task_file).parent / ".acceptance-manifest.json"
|
|
837
|
+
if manifest.is_file():
|
|
838
|
+
from codeflow.acceptance.cf_acceptance_evidence import invalidate_manifest_evidence
|
|
839
|
+
try:
|
|
840
|
+
stale_rows = invalidate_manifest_evidence(root, manifest, input_hash)
|
|
841
|
+
except (OSError, ValueError) as exc:
|
|
842
|
+
raise ParallelError("stale_acceptance_failed", f"{manifest}: {exc}") from exc
|
|
843
|
+
if stale_rows:
|
|
844
|
+
changed.append({"path": manifest.relative_to(root).as_posix(),
|
|
845
|
+
"scenarios": [row["scenario_id"] for row in stale_rows],
|
|
846
|
+
"checks": sorted({check for row in stale_rows for check in row["checks"]})})
|
|
847
|
+
return changed
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _refresh_demand(root: Path, task_file: str) -> str:
|
|
851
|
+
demand = root / Path(task_file).parent
|
|
852
|
+
script = root / ".code-flow" / "scripts" / "cf_spec_context.py"
|
|
853
|
+
result = subprocess.run(
|
|
854
|
+
(sys.executable, str(script), "refresh", "--task-dir", str(demand), "--root", str(root), "--json"),
|
|
855
|
+
cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False,
|
|
856
|
+
)
|
|
857
|
+
if result.returncode == 0:
|
|
858
|
+
return "pass"
|
|
859
|
+
detail = (result.stdout or result.stderr or "").strip()
|
|
860
|
+
return f"block: {detail[-200:]}"
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
_BATCH_SMOKE_BLOCKING = ("failed", "env_blocked", "incomplete")
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _smoke_status(result: Mapping[str, object]) -> str:
|
|
867
|
+
if result.get("decision") == "pass":
|
|
868
|
+
return "passed"
|
|
869
|
+
if int(result.get("env_blocked") or 0) > 0:
|
|
870
|
+
return "env_blocked"
|
|
871
|
+
if int(result.get("incomplete") or 0) > 0:
|
|
872
|
+
return "incomplete"
|
|
873
|
+
return "failed"
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _smoke_summary(result: Mapping[str, object]) -> dict[str, object]:
|
|
877
|
+
summary: dict[str, object] = {
|
|
878
|
+
"status": _smoke_status(result),
|
|
879
|
+
"profile": "smoke",
|
|
880
|
+
"changed": True,
|
|
881
|
+
"decision": result.get("decision", "block"),
|
|
882
|
+
"reason": result.get("reason", ""),
|
|
883
|
+
"executed": int(result.get("executed") or 0),
|
|
884
|
+
"reused": int(result.get("reused") or 0),
|
|
885
|
+
"failed": list(result.get("failed") or ()),
|
|
886
|
+
"env_blocked": int(result.get("env_blocked") or 0),
|
|
887
|
+
"incomplete": int(result.get("incomplete") or 0),
|
|
888
|
+
}
|
|
889
|
+
layers = result.get("layers")
|
|
890
|
+
if isinstance(layers, Mapping):
|
|
891
|
+
summary["layers"] = {
|
|
892
|
+
str(name): {
|
|
893
|
+
key: layer.get(key)
|
|
894
|
+
for key in ("decision", "reason", "executed", "reused", "env_blocked", "incomplete", "failed")
|
|
895
|
+
}
|
|
896
|
+
for name, layer in layers.items() if isinstance(layer, Mapping)
|
|
897
|
+
}
|
|
898
|
+
return summary
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _run_batch_smoke(root: Path, run_id: str, task_file: str,
|
|
902
|
+
task_ids: Sequence[str]) -> dict[str, object]:
|
|
903
|
+
"""One post-merge smoke for the whole batch, reusing the layer runner."""
|
|
904
|
+
from codeflow.task.cf_requirement_verification import verify_layers
|
|
905
|
+
|
|
906
|
+
directory = root / Path(task_file).parent
|
|
907
|
+
try:
|
|
908
|
+
result = verify_layers(str(root), str(directory), layers="validators,e2e",
|
|
909
|
+
profile="smoke", only_tasks=list(task_ids))
|
|
910
|
+
except (OSError, ValueError) as exc:
|
|
911
|
+
summary = {
|
|
912
|
+
"status": "failed", "profile": "smoke", "changed": True,
|
|
913
|
+
"decision": "block", "reason": "batch_smoke_error", "detail": str(exc),
|
|
914
|
+
"executed": 0, "reused": 0, "failed": [], "env_blocked": 0, "incomplete": 0,
|
|
915
|
+
}
|
|
916
|
+
else:
|
|
917
|
+
summary = _smoke_summary(result)
|
|
918
|
+
_update_run_meta(root, run_id, {
|
|
919
|
+
"batch_smoke": summary,
|
|
920
|
+
"batch_smoke_at": datetime.now(timezone.utc).isoformat(),
|
|
921
|
+
})
|
|
922
|
+
return summary
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _merge_entries(root: Path, main_branch: str, entries: Sequence[dict[str, object]],
|
|
926
|
+
task_file: str) -> list[dict[str, object]]:
|
|
927
|
+
results: list[dict[str, object]] = []
|
|
928
|
+
for entry in entries:
|
|
929
|
+
try:
|
|
930
|
+
results.append(_merge_one(root, main_branch, entry, task_file))
|
|
931
|
+
except ParallelError as exc:
|
|
932
|
+
failure = {
|
|
933
|
+
"task": str(entry.get("task", "")), "ok": False,
|
|
934
|
+
"code": exc.code, "message": exc.message,
|
|
935
|
+
}
|
|
936
|
+
failure.update(exc.details)
|
|
937
|
+
results.append(failure)
|
|
938
|
+
break
|
|
939
|
+
return results
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def merge(root: Path, run_id: str, tasks: Optional[Sequence[str]]) -> dict[str, object]:
|
|
943
|
+
root = root.resolve()
|
|
944
|
+
_assert_repo(root)
|
|
945
|
+
meta = _read_run_meta(root, run_id)
|
|
946
|
+
main_branch = _run_git(root, ("rev-parse", "--abbrev-ref", "HEAD")).strip()
|
|
947
|
+
if main_branch == "HEAD":
|
|
948
|
+
raise ParallelError("detached_head", "主工作区处于 detached HEAD,无法回并")
|
|
949
|
+
wanted = set(tasks) if tasks else None
|
|
950
|
+
all_entries = [item for item in meta.get("worktrees", []) if isinstance(item, dict)]
|
|
951
|
+
all_task_ids = {str(item.get("task", "")) for item in all_entries}
|
|
952
|
+
full_batch = wanted is None or wanted >= all_task_ids
|
|
953
|
+
entries = all_entries
|
|
954
|
+
if wanted:
|
|
955
|
+
entries = [item for item in entries if item.get("task") in wanted]
|
|
956
|
+
entries.sort(key=lambda item: str(item.get("task", "")))
|
|
957
|
+
task_file = str(meta["task_file"])
|
|
958
|
+
_update_run_meta(root, run_id, {
|
|
959
|
+
"merge": {
|
|
960
|
+
"status": "running",
|
|
961
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
962
|
+
}
|
|
963
|
+
})
|
|
964
|
+
before_hash = worktree_fingerprint(str(root))
|
|
965
|
+
results = _merge_entries(root, main_branch, entries, task_file)
|
|
966
|
+
ok = bool(results) and all(item.get("ok") for item in results)
|
|
967
|
+
payload: dict[str, object] = {
|
|
968
|
+
"ok": ok, "action": "merge", "run_id": run_id,
|
|
969
|
+
"main_branch": main_branch, "results": results,
|
|
970
|
+
}
|
|
971
|
+
after_hash = worktree_fingerprint(str(root))
|
|
972
|
+
if after_hash and (before_hash != after_hash or any(row.get("status") == "merged" for row in results)):
|
|
973
|
+
payload["input_hash"] = after_hash
|
|
974
|
+
payload["stale"] = _stale_after_merge(root, task_file, after_hash)
|
|
975
|
+
if ok:
|
|
976
|
+
if (root / Path(task_file).parent / "spec-context.yml").is_file():
|
|
977
|
+
payload["refresh"] = _refresh_demand(root, task_file)
|
|
978
|
+
previous = meta.get("batch_smoke")
|
|
979
|
+
previous_status = previous.get("status") if isinstance(previous, Mapping) else ""
|
|
980
|
+
merged_any = any(item.get("status") == "merged" for item in results)
|
|
981
|
+
if full_batch and (merged_any or previous_status != "passed"):
|
|
982
|
+
smoke = _run_batch_smoke(root, run_id, task_file, sorted(all_task_ids))
|
|
983
|
+
payload["batch_smoke"] = smoke
|
|
984
|
+
if smoke.get("status") != "passed":
|
|
985
|
+
payload["ok"] = False
|
|
986
|
+
payload["code"] = "batch_smoke_failed"
|
|
987
|
+
payload["message"] = (
|
|
988
|
+
f"批次 smoke {smoke.get('status')}: "
|
|
989
|
+
f"{smoke.get('reason') or 'shared environment verification failed'};保留 worktree"
|
|
990
|
+
)
|
|
991
|
+
elif isinstance(previous, Mapping) and previous_status == "passed":
|
|
992
|
+
payload["batch_smoke"] = dict(previous)
|
|
993
|
+
merged_tasks = sorted({
|
|
994
|
+
str(item.get("task")) for item in results
|
|
995
|
+
if item.get("ok") and item.get("status") in ("merged", "already_merged")
|
|
996
|
+
})
|
|
997
|
+
merge_code = str(payload.get("code", "")) or next(
|
|
998
|
+
(str(item.get("code", "")) for item in results if not item.get("ok") and item.get("code")), ""
|
|
999
|
+
)
|
|
1000
|
+
_update_run_meta(root, run_id, {
|
|
1001
|
+
"merge": {
|
|
1002
|
+
"status": "passed" if payload.get("ok") else "blocked",
|
|
1003
|
+
"code": merge_code,
|
|
1004
|
+
"full_batch": full_batch,
|
|
1005
|
+
"merged": merged_tasks,
|
|
1006
|
+
"all_tasks": sorted(all_task_ids),
|
|
1007
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
1008
|
+
}
|
|
1009
|
+
})
|
|
1010
|
+
return payload
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _cleanup_block_reason(meta: Mapping[str, object], force: bool) -> tuple[str, str]:
|
|
1014
|
+
"""Refuse cleanup while the latest merge is not in a passed state."""
|
|
1015
|
+
if force:
|
|
1016
|
+
return "", ""
|
|
1017
|
+
merge_state = meta.get("merge")
|
|
1018
|
+
if isinstance(merge_state, Mapping) and merge_state.get("status") != "passed":
|
|
1019
|
+
code = str(merge_state.get("code") or "")
|
|
1020
|
+
if code == "batch_smoke_failed":
|
|
1021
|
+
return (
|
|
1022
|
+
"batch_smoke_failed",
|
|
1023
|
+
"批次 smoke failed,保留 worktree;修复后重新 merge,或 --force 明确放弃现场",
|
|
1024
|
+
)
|
|
1025
|
+
return (
|
|
1026
|
+
"merge_not_passed",
|
|
1027
|
+
f"最近一次 merge 状态为 {merge_state.get('status') or 'missing'}({code or 'no code'});"
|
|
1028
|
+
"保留 worktree,修复后重新 merge,或 --force 明确放弃现场",
|
|
1029
|
+
)
|
|
1030
|
+
if isinstance(merge_state, Mapping) and "full_batch" in merge_state:
|
|
1031
|
+
merged = {str(item) for item in (merge_state.get("merged") or [])}
|
|
1032
|
+
all_tasks = {str(item) for item in (merge_state.get("all_tasks") or [])}
|
|
1033
|
+
if all_tasks:
|
|
1034
|
+
missing = sorted(all_tasks - merged)
|
|
1035
|
+
else:
|
|
1036
|
+
missing = [
|
|
1037
|
+
str(item.get("task")) for item in meta.get("worktrees", [])
|
|
1038
|
+
if isinstance(item, Mapping)
|
|
1039
|
+
]
|
|
1040
|
+
if merge_state.get("full_batch") is False or missing:
|
|
1041
|
+
return (
|
|
1042
|
+
"partial_merge",
|
|
1043
|
+
f"仍有未合并任务: {', '.join(missing) or 'subset merge'};"
|
|
1044
|
+
"保留 worktree,完成全批次 merge 后再 cleanup,或 --force 明确放弃现场",
|
|
1045
|
+
)
|
|
1046
|
+
smoke = meta.get("batch_smoke")
|
|
1047
|
+
if isinstance(smoke, Mapping) and smoke.get("status") in _BATCH_SMOKE_BLOCKING:
|
|
1048
|
+
return (
|
|
1049
|
+
"batch_smoke_failed",
|
|
1050
|
+
f"批次 smoke {smoke.get('status')},保留 worktree;"
|
|
1051
|
+
"修复后重新 merge,或 --force 明确放弃现场",
|
|
1052
|
+
)
|
|
1053
|
+
return "", ""
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def cleanup(root: Path, run_id: str, force: bool) -> dict[str, object]:
|
|
1057
|
+
root = root.resolve()
|
|
1058
|
+
meta = _read_run_meta(root, run_id)
|
|
1059
|
+
code, message = _cleanup_block_reason(meta, force)
|
|
1060
|
+
if code:
|
|
1061
|
+
return {
|
|
1062
|
+
"ok": False, "action": "cleanup", "run_id": run_id,
|
|
1063
|
+
"code": code, "message": message,
|
|
1064
|
+
"results": [
|
|
1065
|
+
{"task": item.get("task"), "removed": False,
|
|
1066
|
+
"code": code, "message": message}
|
|
1067
|
+
for item in meta.get("worktrees", []) if isinstance(item, dict)
|
|
1068
|
+
],
|
|
1069
|
+
}
|
|
1070
|
+
results: list[dict[str, object]] = []
|
|
1071
|
+
for item in meta.get("worktrees", []):
|
|
1072
|
+
if not isinstance(item, dict):
|
|
1073
|
+
continue
|
|
1074
|
+
path = Path(str(item.get("path", "")))
|
|
1075
|
+
if not path.exists():
|
|
1076
|
+
results.append({"task": item.get("task"), "removed": False, "reason": "already_absent"})
|
|
1077
|
+
continue
|
|
1078
|
+
try:
|
|
1079
|
+
_require_clean_for_removal(path, force)
|
|
1080
|
+
_remove_worktree(root, path, force)
|
|
1081
|
+
branch = str(item.get("branch", ""))
|
|
1082
|
+
merged = _delete_branch(root, branch, force) if branch else False
|
|
1083
|
+
results.append({"task": item.get("task"), "removed": True, "branch_deleted": merged})
|
|
1084
|
+
except ParallelError as exc:
|
|
1085
|
+
results.append({"task": item.get("task"), "removed": False, "code": exc.code, "message": exc.message})
|
|
1086
|
+
_run_git(root, ("worktree", "prune"))
|
|
1087
|
+
run_dir = root / WORKTREES_DIR / run_id
|
|
1088
|
+
if run_dir.is_dir() and not any(child.is_dir() for child in run_dir.iterdir()):
|
|
1089
|
+
(run_dir / "run.json").unlink(missing_ok=True)
|
|
1090
|
+
run_dir.rmdir()
|
|
1091
|
+
ok = all(item.get("removed") or item.get("reason") == "already_absent" for item in results)
|
|
1092
|
+
return {"ok": ok, "action": "cleanup", "run_id": run_id, "results": results}
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _require_clean_for_removal(path: Path, force: bool) -> None:
|
|
1096
|
+
if force:
|
|
1097
|
+
return
|
|
1098
|
+
dirty = _status_paths(path)
|
|
1099
|
+
if dirty:
|
|
1100
|
+
raise ParallelError("worktree_dirty", f"worktree 有未提交改动: {', '.join(dirty[:8])}")
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _emit(payload: dict[str, object], stdout: IO[str]) -> None:
|
|
1104
|
+
stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
1108
|
+
parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_task_parallel.py"))
|
|
1109
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
1110
|
+
for name in ("prepare", "collect", "cleanup", "merge"):
|
|
1111
|
+
command = sub.add_parser(name)
|
|
1112
|
+
command.add_argument("--root", default=".")
|
|
1113
|
+
command.add_argument("--json", action="store_true")
|
|
1114
|
+
prepare_parser = sub.choices["prepare"]
|
|
1115
|
+
prepare_parser.add_argument("--task-file", required=True)
|
|
1116
|
+
prepare_parser.add_argument("--tasks", required=True)
|
|
1117
|
+
prepare_parser.add_argument("--run-id", default="")
|
|
1118
|
+
for name in ("collect", "cleanup", "merge"):
|
|
1119
|
+
sub.choices[name].add_argument("--run-id", required=True)
|
|
1120
|
+
sub.choices["collect"].add_argument("--tasks", default="")
|
|
1121
|
+
sub.choices["collect"].add_argument("--no-commit", action="store_true")
|
|
1122
|
+
sub.choices["cleanup"].add_argument("--force", action="store_true")
|
|
1123
|
+
sub.choices["merge"].add_argument("--tasks", default="")
|
|
1124
|
+
args = parser.parse_args(argv)
|
|
1125
|
+
root = Path(args.root).resolve()
|
|
1126
|
+
try:
|
|
1127
|
+
if args.action == "prepare":
|
|
1128
|
+
tasks = [item.strip() for item in args.tasks.split(",") if item.strip()]
|
|
1129
|
+
payload = prepare(root, args.task_file, tasks, args.run_id or None)
|
|
1130
|
+
elif args.action == "collect":
|
|
1131
|
+
tasks = [item.strip() for item in args.tasks.split(",") if item.strip()] or None
|
|
1132
|
+
payload = collect(root, args.run_id, tasks, commit=not args.no_commit)
|
|
1133
|
+
elif args.action == "merge":
|
|
1134
|
+
tasks = [item.strip() for item in args.tasks.split(",") if item.strip()] or None
|
|
1135
|
+
payload = merge(root, args.run_id, tasks)
|
|
1136
|
+
else:
|
|
1137
|
+
payload = cleanup(root, args.run_id, args.force)
|
|
1138
|
+
except ParallelError as exc:
|
|
1139
|
+
payload = {"ok": False, "action": args.action, "code": exc.code, "message": exc.message}
|
|
1140
|
+
payload.update(exc.details)
|
|
1141
|
+
_emit(payload, stdout)
|
|
1142
|
+
return 0 if payload.get("ok") else 2
|