Files
AirPlan-V2/lib/air_runtime/modes/do_mode.py
AirPlan 6130478c96 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>
2026-06-12 15:56:44 +08:00

371 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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', []))}")