Files
AirPlan-V2/lib/air_runtime/modes/do_mode.py
AirPlan 9702f1b186 feat: 3.2.17中途变更处理(git驱动) + 3.2.9a Worker git强制 + 3.2.5 Phase7 squash merge + Arc git初始化检测
- 新增 ChangeClassifier(爆炸半径分类:IMPLEMENTATION/INTERFACE/GLOBAL_CONSTRAINT)
- 新增 ImpactPropagator(BFS影响传播:IMPACTED/BOUNDARY/SAFE差异化标记)
- task_graph.py:invalidate_by_adr()差异化失效 + CascadeReport扩展字段(向后兼容)
- eng_mode.py:三阶段差异化流程(分类→传播→失效→git操作→验证任务→重规划)
- eng_mode.py:_git_squash_merge_and_tag() + Phase 7 集成
- do_mode.py:_ensure_all_committed() Worker git操作强制
- adr_watcher.py:内容快照 + get_content_for_classification()
- events.py:ADR_CLASSIFIED/IMPACT_PROPAGATED/BOUNDARY_VERIFICATION_GENERATED
- partial_replanner.py:replan_with_constraints() + generate_verification_tasks()
- project_bootstrap.py:ensure_git_initialized() Arc规划前检测
- test_p1_21_phase2.py:21个新测试,95个全量测试0失败

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 14:45:41 +08:00

398 lines
15 KiB
Python
Executable File
Raw Permalink 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
def _ensure_all_committed(project_root: Path, task_id: str) -> None:
"""3.2.9a: Worker git 操作强制 — 检查所有变更已提交。
在 Worker finish 前强制检查 git status未提交的变更阻止完成。
确保 Worktree 分支上所有修改都已 git add + git commit。
"""
import subprocess
try:
r = subprocess.run(
["git", "status", "--porcelain"],
cwd=project_root, capture_output=True, text=True, timeout=10,
)
if r.returncode != 0:
return # git not available, skip check
if r.stdout.strip():
raise RuntimeError(
f"Worker {task_id}: 存在未提交的变更,禁止 finish。\n"
f"未提交文件:\n{r.stdout.strip()}\n"
f"请先 git add && git commit 后再 finish。"
)
except FileNotFoundError:
pass # git not available, skip check
# 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)
# 3.2.9a: Worker git 操作强制 — 所有变更必须已提交
_ensure_all_committed(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', []))}")