feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化

P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由
- GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg
- 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过

P1-GAP17: 事件 emit 规范化
- 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等)
- 全部 emit 调用替换字符串字面量为常量,零残留
- 30 个事件类型常量全部定义且唯一

P1-GAP18: 事件日志原子轮转
- emit 计数器每 128 次检查轮转,避免每次 emit 读文件
- 清除未使用的 _emit_with_completion/_pending_merge_complete
- 原子轮转: tempfile+os.replace 保证不损坏

eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr)
commands/do.md: 更新为全专家插件路由文档

全量测试: 69 通过, 0 失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirPlan
2026-06-12 15:56:44 +08:00
commit 6130478c96
73 changed files with 10622 additions and 0 deletions

View 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.mdPhase 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_writecontent 为 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