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

370
lib/air_runtime/modes/do_mode.py Executable file
View File

@@ -0,0 +1,370 @@
"""
AirDo mode — V2 任务执行器。
V2 改进全专家插件强制路由L1 代码级task_id 注入防护UI 任务 frontend-design Skill 路由P1-20
路由规则GUI→XDB, network→NDB, C/C++→SDB, blocked/failed→Dbg, done→Rvr
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, TASK_COMPLETED, TASK_BLOCKED, TASK_ENTERED, TASK_FINISHED
from air_runtime.contracts import WorkerResult, now_iso
from air_runtime.utils import sanitize_task_id, session_stamp
# GUI 任务检测关键词
GUI_INDICATORS = {
"gui", "ui", "render", "layout", "dialog", "osd",
"overlay", "visual", "screenshot", "display",
"widget", "pane", "toolbar", "settings_dialog",
"界面", "渲染", "布局", "按钮", "对话框", "窗口", "菜单", "控件",
}
# 网络任务检测关键词
NETWORK_INDICATORS = {
"network", "rtsp", "http", "tcp", "udp", "tls",
"dns", "proxy", "socket", "stream", "port",
"网络", "抓包", "rtmp", "webrtc", "sip",
}
# C/C++ 文件扩展名
CPP_EXTENSIONS = {".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".h", ".h++"}
def _has_gui_indicators(task_text: str, files_dirs: str) -> bool:
text = f"{task_text} {files_dirs}".lower()
return any(kw in text for kw in GUI_INDICATORS)
def _has_network_indicators(task_text: str, files_dirs: str) -> bool:
text = f"{task_text} {files_dirs}".lower()
return any(kw in text for kw in NETWORK_INDICATORS)
def _has_cpp_files(files_changed: list[str]) -> bool:
return any(
any(f.endswith(ext) for ext in CPP_EXTENSIONS)
for f in files_changed
)
# P1-20: UI 任务检测关键词(支持中英文)
UI_TASK_INDICATORS = (
# 英文关键词
"gui", "ui", "render", "layout", "dialog", "osd",
"overlay", "visual", "screenshot", "display",
"widget", "pane", "toolbar", "settings_dialog",
"canvas", "button", "window", "popup", "menu",
"drm", "kms", "opengl", "vulkan", "frontend",
"react", "vue", "angular", "web", "css", "html",
# 中文关键词
"界面", "UI", "界面设计", "前端", "界面开发",
"按钮", "对话框", "窗口", "菜单", "控件",
"渲染", "布局", "登录界面", "界面组件",
)
def is_ui_task(task_text: str) -> bool:
"""P1-20: 检测任务是否涉及 UI/前端界面设计。"""
text = task_text.lower()
return any(kw in text for kw in UI_TASK_INDICATORS)
def ensure_frontend_design_skill() -> bool:
"""P1-20: 检测 frontend-design Skill 是否存在,不存在则尝试自动安装。"""
# 检查 skill 是否已安装(检查 ~/.claude/skills/frontend-design 或类似路径)
import os
home = Path.home()
skill_path = home / ".claude" / "skills" / "frontend-design"
if skill_path.exists():
return True
# 尝试自动安装
import logging
logging.info("frontend-design skill not found, attempting auto-install...")
try:
result = subprocess.run(
["claude", "plugin", "install", "frontend-design"],
capture_output=True, text=True, timeout=60,
)
if result.returncode == 0:
logging.info("frontend-design skill installed successfully")
return True
logging.warning("frontend-design skill install failed: %s", result.stderr)
except Exception as e:
logging.warning("frontend-design skill install error: %s", e)
return False
def route_ui_task(task_text: str, task_id: str) -> dict:
"""P1-20: UI 任务路由决策。检测 UI 任务并确保 frontend-design Skill 可用。"""
if not is_ui_task(task_text):
return {"target": "execute", "skill": None, "is_ui_task": False}
# 是 UI 任务,检查 skill 可用性
if ensure_frontend_design_skill():
return {"target": "execute", "skill": "frontend-design", "is_ui_task": True}
# Skill 不可用,阻止任务
return {
"target": "blocked",
"reason": "UI task requires frontend-design skill but installation failed",
"skill": "frontend-design",
"is_ui_task": True,
}
def _paths(project_root: Path, task_id: str) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airdo"
task_dir = root / "tasks" / task_id
return {
"root": root,
"state": root / "state.json",
"task_dir": task_dir,
"brief": task_dir / "brief.md",
"handoff": task_dir / "subagent-handoff.md",
"result": task_dir / "result.json",
"worker_state": task_dir / "worker-state.json",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
paths["task_dir"].mkdir(parents=True, exist_ok=True)
def enter_worker(project_root: Path, task_id: str, task_text: str = "") -> dict:
"""P1-20: 新增 task_text 参数用于 UI 任务检测。"""
tid = sanitize_task_id(task_id)
paths = _paths(project_root, tid)
_ensure_dirs(paths)
# P1-20: UI 任务检测和路由
ui_routing = {"target": "execute", "skill": None, "is_ui_task": False}
if task_text:
ui_routing = route_ui_task(task_text, tid)
if ui_routing.get("target") == "blocked":
# UI 任务但 skill 不可用,阻止执行
worker_state = {
"taskId": tid, "status": "blocked",
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
"blockReason": ui_routing.get("reason", "frontend-design skill unavailable"),
"uiRouting": ui_routing,
}
atomic_json_write(paths["worker_state"], worker_state)
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
"updatedAt": now_iso(), "blocked": True})
log = EventLog(event_log_path(project_root))
log.emit(TASK_BLOCKED, {"taskId": tid, "reason": ui_routing.get("reason")})
return {
"taskId": tid, "status": "blocked",
"blockReason": ui_routing.get("reason"),
"uiRouting": ui_routing,
}
worker_state = {
"taskId": tid, "status": "implementing",
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
"uiRouting": ui_routing,
}
atomic_json_write(paths["worker_state"], worker_state)
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
"updatedAt": now_iso()})
log = EventLog(event_log_path(project_root))
log.emit(TASK_ENTERED, {"taskId": tid, "uiRouting": ui_routing})
return {
"taskId": tid, "briefPath": str(paths["brief"]),
"handoffPath": str(paths["handoff"]),
"resultPath": str(paths["result"]),
"workerStatePath": str(paths["worker_state"]),
"uiRouting": ui_routing,
}
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
"""V2 核心改进:全专家插件强制路由。
路由规则(按优先级):
1. blocked/failed → AirDbg调试定位根因
2. done 无证据 → AirDbg审查验证
3. GUI 任务 → AirXDB截图取证
4. 网络任务 → AirNDB抓包取证
5. C/C++ 任务 → AirSDB静态分析
6. 所有 done 任务 → AirRvr需求一致性审查
无强制路由时才允许 merge。
"""
tid = sanitize_task_id(task_id)
paths = _paths(project_root, tid)
# 加载 result
if result_path and result_path.exists():
result_data = safe_json_load(result_path)
elif paths["result"].exists():
result_data = safe_json_load(paths["result"])
else:
result_data = {"taskId": tid, "status": "blocked", "summary": "no result found"}
if not isinstance(result_data, dict):
result_data = {"taskId": tid, "status": "blocked"}
result = WorkerResult.from_dict(result_data)
status = result.status
# 从 task-graph.json 获取任务描述用于分类
task_text = ""
files_dirs = ""
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
if tg_json.exists():
from air_runtime.task_graph import TaskGraph
graph = TaskGraph.load(tg_json)
node = graph.nodes.get(tid)
if node:
task_text = node.task
files_dirs = node.files_dirs
decisions = []
# 1. blocked/failed → 强制 AirDbg最高优先级
if status in ("blocked", "failed"):
decisions.append({
"target": "airdbg", "forced": True,
"reason": f"status={status} — AirDbg mandatory before return",
})
# 2. done 但无实质验证 → 强制 AirDbg
elif status == "done":
if not result.validations and not result.files_changed:
decisions.append({
"target": "airdbg", "forced": True,
"reason": "done without evidence — mandatory debug review",
})
# 3. GUI 任务 → 强制 AirXDB 截图
if _has_gui_indicators(task_text, files_dirs):
xdb_sessions = result_data.get("xdbSessions") or result_data.get("xdb_sessions") or []
if not xdb_sessions:
decisions.append({
"target": "airxdb", "forced": True,
"reason": "GUI task requires screenshot evidence",
})
# 4. 网络任务 → 强制 AirNDB 抓包
if _has_network_indicators(task_text, files_dirs):
ndb_sessions = result_data.get("ndbSessions") or result_data.get("ndb_sessions") or []
if not ndb_sessions:
decisions.append({
"target": "airndb", "forced": True,
"reason": "network task requires packet capture evidence",
})
# 5. C/C++ 任务 → 强制 AirSDB 静态分析
if _has_cpp_files(result.files_changed):
sdb_reports = result_data.get("sdbReports") or result_data.get("sdb_reports") or []
if not sdb_reports:
decisions.append({
"target": "airsdb", "forced": True,
"reason": "C/C++ task requires static analysis",
})
# 6. 所有 done 任务 → 强制 AirRvr 审查(已完成则跳过)
rvr_reviewed = (
result_data.get("rvrReviewed") or
result_data.get("rvr_reviewed") or
result_data.get("rvrReviews") or
result_data.get("rvr_reviews") or
[]
)
if not rvr_reviewed:
decisions.append({
"target": "airrvr", "forced": True,
"reason": "completed task requires requirements review",
})
# 无强制路由时才允许 merge
if not decisions:
decisions.append({"target": "merge", "forced": False})
# 持久化
finalized = result.to_dict()
finalized["routingDecisions"] = decisions
finalized["routingDecision"] = decisions[0] # 向后兼容:主路由决策
finalized["finalizedAt"] = now_iso()
atomic_json_write(paths["result"], finalized)
atomic_json_write(paths["worker_state"], {
"taskId": tid, "status": "finished",
"resultPath": str(paths["result"]),
"routingDecisions": decisions,
"routingDecision": decisions[0],
})
log = EventLog(event_log_path(project_root))
log.emit(TASK_FINISHED, {
"taskId": tid, "status": status,
"routingTargets": [d["target"] for d in decisions],
})
# emit task.completed / task.blocked based on final status
if status == "done":
log.emit(TASK_COMPLETED, {"taskId": tid,
"routingTargets": [d["target"] for d in decisions]})
elif status in ("blocked", "failed"):
log.emit(TASK_BLOCKED, {"taskId": tid, "status": status})
return {
"taskId": tid, "status": status,
"finalizedResultPath": str(paths["result"]),
"workerStatePath": str(paths["worker_state"]),
"routingDecisions": decisions,
"routingDecision": decisions[0],
}
def status_worker(project_root: Path) -> dict:
paths = _paths(project_root, "_")
state = safe_json_load(paths["state"]) or {}
task_ids = []
if paths["root"].joinpath("tasks").exists():
task_ids = [d.name for d in paths["root"].joinpath("tasks").iterdir() if d.is_dir()]
return {
"enabled": state.get("enabled", False),
"activeTaskId": state.get("activeTaskId", ""),
"taskIds": task_ids,
}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
tid = args.task_id
if sub == "status":
s = status_worker(project_root)
print("airplan_mode=do")
print(f"enabled={s['enabled']}")
print(f"active_task_id={s['activeTaskId']}")
print(f"known_tasks={','.join(s['taskIds'])}")
elif sub == "enter":
task_text = getattr(args, "task_text", "") or ""
result = enter_worker(project_root, tid, task_text=task_text)
print("airplan_mode=do")
print(f"task_id={result['taskId']}")
print(f"brief_path={result['briefPath']}")
print(f"result_path={result['resultPath']}")
print(f"worker_state_path={result['workerStatePath']}")
elif sub == "finish":
rpath = Path(args.result).expanduser().resolve() if args.result else None
finalized = finish_worker(project_root, tid, rpath)
targets = [d["target"] for d in finalized.get("routingDecisions", [])]
print("airplan_mode=do")
print(f"task_id={finalized['taskId']}")
print(f"status={finalized['status']}")
print(f"routing_targets={','.join(targets)}")
print(f"routing_forced={any(d.get('forced') for d in finalized.get('routingDecisions', []))}")