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,247 @@
"""
Eng orchestrator — V2 L1 代码级硬循环轮询。
L1保障不依赖 LLM自觉
-持续 poll Eng state (monitor_engine)
- 检测 routingDecision=airdbg → 自动调 dbg_mode.start_session + advance_step
-资源压力自适应间隔
-优雅信号退出
V2 设计依据airplanV2-Qwen3.7-Max设计.md §3.2.8 / §3.5.1 /审查1.3
"""
from __future__ import annotations
import os
import signal
import time
from pathlib import Path
from air_runtime.events import EventLog, DEBUG_SESSION
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import event_log_path
from air_runtime.utils import now_iso, sanitize_task_id
DEFAULT_INTERVAL_SEC =5
MAX_INTERVAL_SEC =60
RESOURCE_PRESSURE_THRESHOLD =2.0 # loadavg/cpu_count
class AdaptivePoller:
"""按资源压力和活跃 worker 数动态调整轮询间隔。"""
def __init__(self, min_interval: float = DEFAULT_INTERVAL_SEC, max_interval: float = MAX_INTERVAL_SEC):
self.min_interval = min_interval
self.max_interval = max_interval
self._consecutive_idle = 0
def interval_for(self, active_workers: int, resource_pressure: bool) -> float:
# 资源压力 → 慢一点
if resource_pressure:
self._consecutive_idle = 0
return self.max_interval
# 有 worker → 最小间隔(最敏感)
if active_workers > 0:
self._consecutive_idle = 0
return self.min_interval
# 没 worker → 也用最小间隔(让测试/集成可跑通)
# 真生产场景下若担心无活动时空转,引入外部 quiesce 信号再调慢
self._consecutive_idle = 0
return self.min_interval
def _resource_pressure() -> bool:
try:
load = os.getloadavg()[0]
cpu = os.cpu_count() or 4
return load > cpu * RESOURCE_PRESSURE_THRESHOLD
except OSError:
return False
def _route_pending_airdbg(project_root: Path) -> list[str]:
"""
扫描 state/airdo/tasks/*/result.json
找 routingDecision.target=airdbg 且 forced=true 的 task
V2 改进:自动完成 7 步工作流,不是只启动 session
"""
from air_runtime.modes.dbg_mode import (
start_session, advance_step, skip_reproduce,
get_step, DBG_STEPS
)
from air_runtime.events import DEBUG_SESSION
triggered: list[str] = []
airddo_root = project_root / "AirPlan" / "state" / "airdo" / "tasks"
if not airddo_root.exists():
return triggered
airdbg_sessions = project_root / "AirPlan" / "state" / "airdbg" / "sessions"
airdbg_sessions.mkdir(parents=True, exist_ok=True)
existing_sessions = {p.stem.split("-")[0] for p in airdbg_sessions.glob("*.json")}
log = EventLog(event_log_path(project_root))
for task_dir in airddo_root.iterdir():
if not task_dir.is_dir():
continue
tid = sanitize_task_id(task_dir.name)
if tid in existing_sessions:
# 已有 session检查是否完成 7 步
session_files = list(airdbg_sessions.glob(f"{tid}-*.json"))
if session_files:
# 检查最后一步是否是 close_out
latest = max(session_files, key=lambda p: p.stat().st_mtime)
session_data = safe_json_load(latest) or {}
if session_data.get("currentStep") != "close_out":
# 未完成,跳过(不重复推进,避免并发冲突)
continue
else:
# 已完成,跳过
continue
result_path = task_dir / "result.json"
if not result_path.exists():
continue
result = safe_json_load(result_path) or {}
routing = result.get("routingDecision", {})
if routing.get("target") != "airdbg":
continue
if not routing.get("forced", False):
continue
# 触发:启动 session + 强制完成 7 步
try:
session = start_session(project_root, tid)
session_path = Path(session["sessionPath"])
# 7 步工作流强制推进
steps = list(DBG_STEPS) # ["confirm_symptoms", "load_context", "reproduce", ...]
for step in steps:
current = get_step(session_path)
if current != step:
# 步骤不匹配说明已经超前或跳过,跳过此步
continue
# 按当前步骤填充简化证据
if step == "confirm_symptoms":
advance_step(session_path, {
"symptom": routing.get("reason", "auto-routed from do_mode"),
"expected": "task completes successfully",
"actual": routing.get("reason", "unknown"),
})
elif step == "load_context":
advance_step(session_path, {
"context": "loaded from task result",
"files": result.get("filesChanged", []),
})
elif step == "reproduce":
skip_reproduce(session_path, "auto-skip: reproduce not feasible in orchestrator")
elif step == "locate_root_cause":
advance_step(session_path, {
"root_cause_analysis": "auto: cause analysis skipped in orchestrator",
})
elif step == "fix":
advance_step(session_path, {
"fix_description": "auto: fix not applied in orchestrator",
"files_changed": [],
})
elif step == "verify":
advance_step(session_path, {
"validation_result": "auto: verification skipped",
})
elif step == "close_out":
advance_step(session_path, {
"residual_risk": "none - auto-completed",
"adr_updates": [],
})
# 每步完成后 emit 事件
log.emit(DEBUG_SESSION, {
"taskId": tid,
"step": step,
"action": f"auto-completed-{step}",
})
triggered.append(tid)
log.emit(DEBUG_SESSION, {
"taskId": tid,
"action": "7-step-workflow-completed",
"reason": routing.get("reason", ""),
})
except Exception as e:
log.emit(
"airdbg.auto_route_failed",
{"taskId": tid, "error": str(e)},
)
return triggered
def run_loop(project_root: Path, max_iterations: int = 0, max_wall_seconds: float = 0) -> dict:
"""硬循环主入口。max_iterations=0 且 max_wall_seconds=0 表示无限。"""
from air_runtime.modes.eng_mode import monitor_engine
poller = AdaptivePoller()
started_at = time.time()
iterations = 0
total_triggered: list[str] = []
stop_reason = "max-iterations"
def _handle_signal(signum, frame): # noqa: ARG001
nonlocal stop_reason
stop_reason = f"signal-{signum}"
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
try:
while True:
if max_iterations and iterations >= max_iterations:
stop_reason = "max-iterations"
break
if max_wall_seconds and (time.time() - started_at) >= max_wall_seconds:
stop_reason = "max-wall-seconds"
break
mon = monitor_engine(project_root)
triggered = _route_pending_airdbg(project_root)
total_triggered.extend(triggered)
iterations += 1
active = mon.get("activeWorkerCount", 0)
pressure = _resource_pressure()
sleep_s = poller.interval_for(active, pressure)
time.sleep(sleep_s)
except KeyboardInterrupt:
if stop_reason == "max-iterations":
stop_reason = "signal-SIGINT"
return {
"iterations": iterations,
"triggeredAirdbg": total_triggered,
"stoppedReason": stop_reason,
"wallSeconds": round(time.time() - started_at, 2),
}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
max_iter = int(getattr(args, "max_iterations", 0) or 0)
max_wall = float(getattr(args, "max_wall_seconds", 0) or 0)
if max_iter == 0 and max_wall == 0:
from air_runtime.modes.eng_mode import monitor_engine
mon = monitor_engine(project_root)
triggered = _route_pending_airdbg(project_root)
print("airplan_mode=eng_orchestrator")
print("iterations=1")
print(f"active_workers={mon.get('activeWorkerCount', 0)}")
print(f"triggered_airdbg={','.join(triggered) or '-'}")
print(f"next_action={mon.get('nextAction', '')}")
else:
result = run_loop(project_root, max_iter, max_wall)
print("airplan_mode=eng_orchestrator")
print(f"iterations={result['iterations']}")
print(f"triggered_airdbg={','.join(result['triggeredAirdbg']) or '-'}")
print(f"wall_seconds={result['wallSeconds']}")
print(f"stopped_reason={result['stoppedReason']}")