AirPlan V2 initial release — unified scheduler with 12 sub-modes
Merges 8 V1 plugins into 1 unified plugin, adds 4 new components (dep, tst, sec, rvr). 12 sub-modes: arc, eng, do, dbg, xdb, sdb, ndb, ctx, dep, tst, sec, rvr. L1 code-level guarantees: atomic writes, file locks, evidence gating, forced debug routing, 3-phase arc gate, evidence-first debug gate, Chinese language lock, scheduler boundary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
238
lib/air_runtime/modes/merge_pipeline.py
Normal file
238
lib/air_runtime/modes/merge_pipeline.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
合并事务化管线 — 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
|
||||
Reference in New Issue
Block a user