""" 合并事务化管线 — 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*") def _strip_merged_refs(row: str) -> str: """去除行内所有已存在的 引用,避免重复 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" " 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