P1-19.1: Arc 边界测试强制 - TaskNode 新增 test_required 字段 - _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务 - Done When 验证必须包含"测试通过" P1-19.2: AirRvr 高风险审计 - 新增 HighRiskAudit, HighRiskFinding 数据类 - ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict - 序列化/反序列化支持 P1-19.3: block-release 集成 - dispatch_worker_group() 派发前扫描最新审查报告 - deliveryVerdict=block-release 时阻止所有后续派发 - 记录 eng.blocked 事件 P1-20: frontend-design Skill 集成 - is_ui_task() UI 任务检测 - ensure_frontend_design_skill() 自动安装 Skill - route_ui_task() UI 任务路由决策 - enter_worker() 集成 UI 检测,skill 不可用时阻止执行 - commands/do.md 更新 UI 处理说明 - SKILL.md 新增 INV-12/INV-13/INV-14 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
239 lines
8.9 KiB
Python
239 lines
8.9 KiB
Python
"""
|
||
合并事务化管线 — V2 引入的 6 阶段合并流水线的纯函数 / 副作用函数集合。
|
||
|
||
从 eng_mode.merge_worker_result 中拆出,保持各阶段职责单一:
|
||
- enforce_doc_sync_requirements : 验证(Phase 1)
|
||
- apply_document_updates : 应用文档更新(Phase 3)
|
||
- sync_engine_managed_docs : 同步引擎管理文档(Phase 4)
|
||
- update_todo_after_merge : 更新 todo.md(Phase 5)
|
||
|
||
所有写盘均依赖 air_runtime.io.atomic_json_write 提供的 POSIX 原子语义;
|
||
更新 todo.md 时由调用方额外嵌套 FileLock 保证与外部协调。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from air_runtime.io import atomic_json_write, safe_json_load
|
||
from air_runtime.paths import airplan_root, todo_path
|
||
from air_runtime.utils import now_iso, session_stamp
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 引擎管理的标记块文档 — Phase 4 默认扫描列表
|
||
_ENGINE_MANAGED_DOCS = (
|
||
"plan.md",
|
||
"debug-log.md",
|
||
"staticanalysis.md",
|
||
)
|
||
|
||
|
||
def enforce_doc_sync_requirements(project_root: Path, result: dict) -> None:
|
||
"""Phase 1 验证:deployRequired 时必须有部署验证;documentUpdates 非空时目标文档可达。
|
||
|
||
失败抛 ValueError。任何抛出都不会触碰文件系统。
|
||
"""
|
||
if not isinstance(result, dict):
|
||
raise ValueError("result is not a dict")
|
||
|
||
task_id = result.get("taskId", "")
|
||
if not task_id:
|
||
raise ValueError("result.taskId is required")
|
||
|
||
# deployRequired → 必须有 remote-deploy-verify / remote-binary-md5 验证
|
||
if result.get("deployRequired"):
|
||
validations = result.get("validations") or []
|
||
has_deploy_check = any(
|
||
isinstance(v, dict) and v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
|
||
for v in validations
|
||
)
|
||
if not has_deploy_check:
|
||
raise ValueError(
|
||
f"deployRequired=true but no deploy verification found for {task_id}"
|
||
)
|
||
|
||
# documentUpdates 非空 → 目标文档路径必须存在(不要求文件存在,但父目录可达)
|
||
# boundary: AirPlan/ 目录(避免状态/缓存散落到项目根)
|
||
doc_updates = result.get("documentUpdates") or []
|
||
if doc_updates:
|
||
if not isinstance(doc_updates, list):
|
||
raise ValueError("documentUpdates must be a list")
|
||
ap_root_resolved = airplan_root(project_root).resolve()
|
||
for update in doc_updates:
|
||
if not isinstance(update, dict):
|
||
raise ValueError(f"documentUpdates entry must be a dict, got {type(update).__name__}")
|
||
rel = update.get("path", "")
|
||
if not rel:
|
||
raise ValueError("documentUpdates entry missing 'path'")
|
||
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
target.resolve().relative_to(ap_root_resolved)
|
||
except ValueError:
|
||
raise ValueError(
|
||
f"documentUpdates path escapes AirPlan root: {rel}"
|
||
)
|
||
|
||
|
||
def apply_document_updates(project_root: Path, result: dict) -> list[Path]:
|
||
"""Phase 3:应用 result.documentUpdates,每个 update = {path, action, content}。
|
||
|
||
写盘用 atomic_json_write(content 为 JSON 可序列化对象)或直接覆盖追加。
|
||
返回成功写入的路径列表。
|
||
"""
|
||
applied: list[Path] = []
|
||
doc_updates = result.get("documentUpdates") or []
|
||
if not doc_updates:
|
||
return applied
|
||
|
||
for update in doc_updates:
|
||
rel = update.get("path", "")
|
||
action = (update.get("action") or "append").lower()
|
||
content = update.get("content", "")
|
||
|
||
target = (project_root / rel) if not Path(rel).is_absolute() else Path(rel)
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
if action == "write":
|
||
# 整体覆盖写入。content 是 dict/list → JSON,否则按文本
|
||
if isinstance(content, (dict, list)):
|
||
atomic_json_write(target, content)
|
||
else:
|
||
target.write_text(str(content), encoding="utf-8")
|
||
elif action == "append":
|
||
# 文本追加
|
||
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||
tail = "" if existing.endswith("\n") or not existing else "\n"
|
||
target.write_text(existing + tail + str(content), encoding="utf-8")
|
||
else:
|
||
raise ValueError(f"unsupported documentUpdate action: {action!r}")
|
||
|
||
applied.append(target)
|
||
logger.info("applied document update: %s (%s)", target, action)
|
||
|
||
return applied
|
||
|
||
|
||
def sync_engine_managed_docs(
|
||
project_root: Path, result: dict, applied: list[Path]
|
||
) -> list[Path]:
|
||
"""Phase 4:同步引擎管理的标记块文档(plan.md / debug-log.md / staticanalysis.md)。
|
||
|
||
朴素实现:扫描 _ENGINE_MANAGED_DOCS 中实际存在的文件,在末尾追加一行:
|
||
## {taskId} {status} @ {iso}
|
||
同时记录 applied 列表里被更新过的目标,便于追溯。
|
||
返回实际写入的 sync 路径列表。
|
||
"""
|
||
task_id = result.get("taskId", "")
|
||
status = result.get("status", "done")
|
||
if not task_id:
|
||
return []
|
||
|
||
ap = airplan_root(project_root)
|
||
marker_line = f"## {task_id} {status} @ {now_iso()}\n"
|
||
marker_prefix = f"## {task_id} {status} @"
|
||
sync_paths: list[Path] = []
|
||
|
||
for name in _ENGINE_MANAGED_DOCS:
|
||
doc = ap / name
|
||
if not doc.exists():
|
||
continue
|
||
existing = doc.read_text(encoding="utf-8")
|
||
# 去重:若该 taskId 的标记行已存在,则不再追加
|
||
if any(line.lstrip().startswith(marker_prefix) for line in existing.splitlines()):
|
||
continue
|
||
tail = "" if existing.endswith("\n") or not existing else "\n"
|
||
doc.write_text(existing + tail + marker_line, encoding="utf-8")
|
||
sync_paths.append(doc)
|
||
logger.info("synced engine-managed doc: %s", doc)
|
||
|
||
return sync_paths
|
||
|
||
|
||
_MERGED_REF_RE = re.compile(r"\s*<!--\s*merged:.*?-->")
|
||
|
||
|
||
def _strip_merged_refs(row: str) -> str:
|
||
"""去除行内所有已存在的 <!-- merged:... --> 引用,避免重复 merge 累积。"""
|
||
return _MERGED_REF_RE.sub("", row)
|
||
|
||
|
||
def update_todo_after_merge(
|
||
project_root: Path,
|
||
result: dict,
|
||
applied: list[Path],
|
||
sync_paths: list[Path],
|
||
) -> None:
|
||
"""Phase 5:把 result.taskId 对应行标记为 DONE,附加 archive 引用。
|
||
|
||
调用方负责 FileLock 包裹以保证与外部并发安全。函数本身直接读写 todo.md。
|
||
"""
|
||
task_id = result.get("taskId", "")
|
||
status = result.get("status", "done")
|
||
if not task_id:
|
||
raise ValueError("result.taskId is required for todo update")
|
||
|
||
tp = todo_path(project_root)
|
||
if not tp.exists():
|
||
logger.warning("todo.md not found at %s, skipping", tp)
|
||
return
|
||
|
||
lines = tp.read_text(encoding="utf-8").splitlines()
|
||
archive_note = ""
|
||
if applied or sync_paths:
|
||
refs = ", ".join(str(p.relative_to(project_root)) for p in (applied + sync_paths))
|
||
archive_note = f" <!-- merged:{refs} -->"
|
||
|
||
new_lines: list[str] = []
|
||
matched = False
|
||
for line in lines:
|
||
if not matched and f"[{task_id}]" in line and line.lstrip().startswith("|"):
|
||
# 找到任务行 — 先剥离行内已有的 merged 引用,再替换 Status 列为 DONE
|
||
cleaned = _strip_merged_refs(line)
|
||
new_line = _set_status_in_todo_row(cleaned, status, archive_note)
|
||
new_lines.append(new_line)
|
||
matched = True
|
||
else:
|
||
new_lines.append(line)
|
||
|
||
if matched:
|
||
tp.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||
logger.info("updated todo.md: %s -> %s", task_id, status)
|
||
else:
|
||
logger.warning("todo.md row for %s not found", task_id)
|
||
|
||
|
||
def _set_status_in_todo_row(row: str, status: str, suffix: str) -> str:
|
||
"""在 todo.md 表格行中把 Status 列替换为目标 status,并附加尾注释。
|
||
|
||
不依赖硬编码列索引 — 复用 parse_tasks 的策略:通过表头动态定位 Status 列。
|
||
"""
|
||
# 解析行:保留前后的 | 边界
|
||
stripped = row.strip()
|
||
if not stripped.startswith("|") or not stripped.endswith("|"):
|
||
return row + suffix
|
||
|
||
inner = stripped[1:-1]
|
||
cells = [c.strip() for c in inner.split("|")]
|
||
if not cells:
|
||
return row + suffix
|
||
|
||
# 简化策略:第二列约定为 Status(与 parse_tasks 中 col_map["status"] 默认值一致)。
|
||
# 若行内出现 "TODO"/"DOING"/"DONE" 等已知状态词,则定位到那一列。
|
||
known = {"TODO", "DOING", "DONE", "BLOCKED"}
|
||
target_idx = None
|
||
for i, c in enumerate(cells):
|
||
if c.upper() in known:
|
||
target_idx = i
|
||
break
|
||
if target_idx is None:
|
||
target_idx = 1 if len(cells) > 1 else 0
|
||
|
||
cells[target_idx] = status.upper()
|
||
new_inner = " | ".join(cells)
|
||
return "| " + new_inner + " |" + suffix
|