AirPlan V2 initial release — unified scheduler with 12 sub-modes

Merges 8 V1 plugins into 1 unified plugin, adds 4 new components (dep, tst, sec, rvr).
12 sub-modes: arc, eng, do, dbg, xdb, sdb, ndb, ctx, dep, tst, sec, rvr.
L1 code-level guarantees: atomic writes, file locks, evidence gating, forced debug routing,
3-phase arc gate, evidence-first debug gate, Chinese language lock, scheduler boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirPlan Team
2026-06-10 16:24:26 +08:00
commit 2c4b3340bf
81 changed files with 6005 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# air_runtime — AirPlan V2 核心运行时库
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.lock import FileLock
from air_runtime.utils import ordered_unique, session_stamp, normalize_policy, sanitize_task_id, sanitize_marker
from air_runtime.events import EventLog
from air_runtime.task_graph import TaskGraph, PlanDelta
from air_runtime.worktree import WorktreeIsolation, RegionConflictDetector, ConflictLevel
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
from air_runtime.contracts import now_iso, WorkerResult, TaskRecord, DeploymentRecord
from air_runtime.paths import (
airplan_root, todo_path, plan_path,
state_root, engine_state_path, worker_state_path,
arc_state_path, dbg_state_path, xdb_state_path,
sdb_state_path, ndb_state_path, ctx_state_path,
dep_state_path, tst_state_path, sec_state_path, rvr_state_path,
required_project_artifacts,
)
__version__ = "2.0.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,109 @@
"""
数据契约 — 保持 V1 契约完整性,新增 DeploymentRecord、AirRvr 审查报告等结构。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass
class WorkerResult:
task_id: str
status: str # done | blocked | failed
summary: str = ""
files_changed: list[str] = field(default_factory=list)
validations: list[dict] = field(default_factory=list)
document_updates: list[dict] = field(default_factory=list)
evidence: list[dict] = field(default_factory=list)
risks: list[str] = field(default_factory=list)
blockers: list[str] = field(default_factory=list)
deploy_required: bool = False
deploy_info: dict | None = None
def validate_for_finalize(self, brief: dict | None = None) -> None:
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", self.task_id):
raise ValidationError(f"invalid task_id: {self.task_id!r}")
if self.status not in ("done", "blocked", "failed"):
raise ValidationError(f"invalid status: {self.status}")
if self.status == "done" and not self.validations and not self.files_changed:
raise ValidationError("done without validations or file changes")
if self.deploy_required:
deploy_validations = [
v for v in self.validations
if v.get("kind") in ("remote-deploy-verify", "remote-binary-md5")
]
if not deploy_validations:
raise ValidationError("deploy_required but no deploy verification in validations")
def to_dict(self) -> dict[str, Any]:
return {
"taskId": self.task_id,
"status": self.status,
"summary": self.summary,
"filesChanged": self.files_changed,
"validations": self.validations,
"documentUpdates": self.document_updates,
"evidence": self.evidence,
"risks": self.risks,
"blockers": self.blockers,
"deployRequired": self.deploy_required,
"deployInfo": self.deploy_info,
}
@classmethod
def from_dict(cls, data: dict) -> WorkerResult:
return cls(
task_id=data.get("taskId", ""),
status=data.get("status", ""),
summary=data.get("summary", ""),
files_changed=data.get("filesChanged", []),
validations=data.get("validations", []),
document_updates=data.get("documentUpdates", []),
evidence=data.get("evidence", []),
risks=data.get("risks", []),
blockers=data.get("blockers", []),
deploy_required=data.get("deployRequired", False),
deploy_info=data.get("deployInfo"),
)
@dataclass
class TaskRecord:
task_id: str
task: str = ""
files_dirs: str = ""
done_when: str = ""
status: str = "TODO"
validations: str = ""
adr: str = ""
@property
def text_for_classification(self) -> str:
return f"{self.task} {self.files_dirs} {self.done_when}"
@dataclass
class DeploymentRecord:
task_id: str
host: str
binary_path: str
md5: str = ""
service_name: str = ""
service_status: str = ""
deploy_at: str = field(default_factory=now_iso)
smoke_test_passed: bool | None = None
class ValidationError(Exception):
pass

View File

@@ -0,0 +1,133 @@
"""
AirDep 部署运行时 — V2 新增组件。
SSH 远程构建 + 二进制传输 + systemd 生命周期管理 + 部署验证。
"""
from __future__ import annotations
import hashlib
import subprocess
from dataclasses import dataclass
from pathlib import Path
from air_runtime.contracts import DeploymentRecord, now_iso
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import dep_state_path
@dataclass
class DeployTarget:
host: str
user: str = "root"
port: int = 22
build_dir: str = "/tmp/airdep-build"
deploy_dir: str = "/opt/app"
@dataclass
class DeployResult:
task_id: str
success: bool
binary_md5: str = ""
service_status: str = ""
error: str = ""
journal_excerpt: str = ""
def _ssh(target: DeployTarget, cmd: str, timeout: int = 120) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=accept-new", "-p", str(target.port),
f"{target.user}@{target.host}", cmd],
capture_output=True, text=True, timeout=timeout,
)
def _scp(target: DeployTarget, local: Path, remote: str, timeout: int = 300) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["scp", "-o", "StrictHostKeyChecking=accept-new", "-P", str(target.port),
str(local), f"{target.user}@{target.host}:{remote}"],
capture_output=True, text=True, timeout=timeout,
)
def _md5_file(path: Path) -> str:
h = hashlib.md5()
with open(path, "rb") as f:
while chunk := f.read(8192):
h.update(chunk)
return h.hexdigest()
def deploy(task_id: str, project_root: Path, target: DeployTarget,
local_binary: Path, service_name: str,
build_cmd: str | None = None,
smoke_test_cmd: str | None = None) -> DeployResult:
"""执行完整部署流程:构建 → 传输 → systemd → 验证。"""
state_file = dep_state_path(project_root)
try:
# Step 1: 远程构建(可选)
if build_cmd:
result = _ssh(target, f"cd {target.build_dir} && {build_cmd}", timeout=600)
if result.returncode != 0:
return DeployResult(task_id=task_id, success=False,
error=f"build failed: {result.stderr[-500:]}")
# Step 2: 二进制传输 + MD5 校验
remote_tmp = f"/tmp/{local_binary.name}"
scp_result = _scp(target, local_binary, remote_tmp)
if scp_result.returncode != 0:
return DeployResult(task_id=task_id, success=False,
error=f"scp failed: {scp_result.stderr[-500:]}")
local_md5 = _md5_file(local_binary)
md5_result = _ssh(target, f"md5sum {remote_tmp} | cut -d' ' -f1")
remote_md5 = md5_result.stdout.strip()
if local_md5 != remote_md5:
return DeployResult(task_id=task_id, success=False,
binary_md5=f"local={local_md5} remote={remote_md5}",
error="md5 mismatch after transfer")
# Step 3: 部署二进制
_ssh(target, f"mv {remote_tmp} {target.deploy_dir}/{local_binary.name}")
# Step 4: systemd 生命周期
_ssh(target, f"systemctl daemon-reload")
_ssh(target, f"systemctl restart {service_name}")
status_result = _ssh(target, f"systemctl is-active {service_name}")
service_status = status_result.stdout.strip()
# Step 5: 冒烟验证(可选)
smoke_passed: bool | None = None
if smoke_test_cmd:
smoke_result = _ssh(target, smoke_test_cmd, timeout=60)
smoke_passed = (smoke_result.returncode == 0)
# Step 6: 记录部署产物
record = DeploymentRecord(
task_id=task_id,
host=target.host,
binary_path=str(target.deploy_dir / local_binary.name),
md5=local_md5,
service_name=service_name,
service_status=service_status,
smoke_test_passed=smoke_passed,
)
# 持久化
sessions = safe_json_load(state_file) or {"sessions": []}
if isinstance(sessions, dict):
sessions.setdefault("sessions", []).append(record.__dict__)
atomic_json_write(state_file, sessions)
return DeployResult(
task_id=task_id,
success=service_status == "active",
binary_md5=local_md5,
service_status=service_status,
)
except subprocess.TimeoutExpired as exc:
return DeployResult(task_id=task_id, success=False, error=f"timeout: {exc}")
except Exception as exc:
return DeployResult(task_id=task_id, success=False, error=str(exc))

102
lib/air_runtime/events.py Normal file
View File

@@ -0,0 +1,102 @@
"""
事件日志模块 — V2 可观测性基础设施。
JSONL 格式无限流式追加,与 state.json 互补state.json 是当前快照,事件日志是完整时间线。
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
# 事件类型常量
TASK_DISPATCHED = "task.dispatched"
TASK_COMPLETED = "task.completed"
TASK_BLOCKED = "task.blocked"
MERGE_STARTED = "merge.started"
MERGE_COMPLETED = "merge.completed"
REPAIR_CREATED = "repair.created"
REPAIR_RESOLVED = "repair.resolved"
INTERVENTION_STALL = "intervention.stall"
XDB_CAPTURED = "xdb.captured"
DEBUG_SESSION = "debug.session"
CONTEXT_COMPACTED = "context.compacted"
DEPLOY_COMPLETED = "deploy.completed"
TEST_RUN = "test.run"
SEC_SCAN = "sec.scan"
REVIEW_SESSION = "review.session"
ENGINE_CYCLE = "engine.cycle"
WORKER_TIMEOUT = "worker.timeout"
LOCK_ACQUIRED = "lock.acquired"
LOCK_RELEASED = "lock.released"
STALE_LOCK_CLEANED = "stale_lock.cleaned"
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class EventLog:
"""结构化事件日志JSONL 格式),支持轮转截断。"""
MAX_LINES = 10000
def __init__(self, path: Path, max_lines: int = MAX_LINES):
self._path = path
self._max_lines = max_lines
self._pending_merge_complete = None
def emit(self, event_type: str, payload: dict | None = None) -> None:
entry = {
"ts": now_iso(),
"type": event_type,
**(payload or {}),
}
self._path.parent.mkdir(parents=True, exist_ok=True)
with open(self._path, "a") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
self._maybe_rotate()
def _emit_with_completion(self, event_type: str, payload: dict) -> None:
"""emit 并确保 MERGE_STARTED / MERGE_COMPLETED 成对。"""
self.emit(event_type, payload)
# 自动补全配对事件
if event_type == "merge.started":
self._pending_merge_complete = payload.get("taskId")
elif event_type == "merge.completed":
self._pending_merge_complete = None
def _maybe_rotate(self) -> None:
"""原子轮转:先写临时文件,再 os.replace。"""
if not self._path.exists():
return
try:
with open(self._path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
if len(lines) <= self._max_lines:
return
keep_count = self._max_lines // 2
kept_lines = lines[-keep_count:] if len(lines) > keep_count else lines
fd, tmp_path = tempfile.mkstemp(dir=self._path.parent, suffix=".tmp")
os.close(fd)
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.writelines(kept_lines)
os.replace(tmp_path, self._path)
logger.info("rotated event log %s, kept %d/%d lines", self._path, len(kept_lines), len(lines))
except Exception:
with contextlib.suppress(Exception):
os.unlink(tmp_path)
raise
except OSError:
pass

View File

@@ -0,0 +1,74 @@
"""
任务类型感知的证据门控 — V2 P0-1 修复。
替代 V1 无差别触发 AirXDB 的逻辑,根据任务特征差异化要求证据类型。
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
class EvidenceClass(enum.Enum):
GUI_REQUIRED = "gui_required" # 需要截图/GUI 操作验证
NETWORK_REQUIRED = "network_required" # 需要抓包/流量验证
STATIC_ANALYSIS = "static_analysis" # 需要静态分析
CODE_ONLY = "code_only" # 代码级验证即可
@dataclass
class EvidenceGatePolicy:
"""基于任务特征的差异化证据要求。
关键词匹配 → 确定性分类,零额外 API 调用,可测试可覆盖。
用户可在 todo.md 中用 [no-xdb] / [no-ndb] 标记显式跳过。
"""
GUI_INDICATORS: tuple[str, ...] = (
"gui", "ui", "render", "layout", "dialog", "osd",
"overlay", "visual", "screenshot", "display",
"widget", "pane", "toolbar", "settings_dialog",
"canvas", "button", "window", "popup", "menu",
"drm", "kms", "opengl", "vulkan",
)
NETWORK_INDICATORS: tuple[str, ...] = (
"network", "rtsp", "http", "tcp", "udp", "tls",
"dns", "proxy", "socket", "stream", "port",
"packet", "pcap", "bandwidth", "latency",
)
STATIC_ANALYSIS_INDICATORS: tuple[str, ...] = (
"cppcheck", "clang-tidy", "mypy", "ruff", "lint",
"static analysis", "compile_commands",
)
SKIP_MARKERS: tuple[str, ...] = ("[no-xdb]", "[no-ndb]", "[no-sdb]")
def classify(self, task_text: str, task_id: str = "", skip_overrides: list[str] | None = None) -> EvidenceClass:
text = task_text.lower()
skips = set(skip_overrides or [])
for marker in self.SKIP_MARKERS:
if marker in text:
skips.add(marker)
has_gui = any(kw in text for kw in self.GUI_INDICATORS)
has_net = any(kw in text for kw in self.NETWORK_INDICATORS)
has_static = any(kw in text for kw in self.STATIC_ANALYSIS_INDICATORS)
if has_gui and "[no-xdb]" not in skips:
return EvidenceClass.GUI_REQUIRED
if has_net and "[no-ndb]" not in skips:
return EvidenceClass.NETWORK_REQUIRED
if has_static and "[no-sdb]" not in skips:
return EvidenceClass.STATIC_ANALYSIS
return EvidenceClass.CODE_ONLY
def required_evidence_for(self, evidence_class: EvidenceClass) -> list[str]:
evidence_map = {
EvidenceClass.GUI_REQUIRED: ["screenshot", "gui-operation-validation"],
EvidenceClass.NETWORK_REQUIRED: ["packet-capture", "connectivity-verification"],
EvidenceClass.STATIC_ANALYSIS: ["static-analysis-report"],
EvidenceClass.CODE_ONLY: ["code-review", "test-results"],
}
return evidence_map.get(evidence_class, ["code-review"])

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
def resolve_plugin_paths() -> dict[str, Path]:
"""
解析插件的实际安装路径。
顺序: 1) 环境变量 AIRPLAN_HOME > 2) ~/.airplan > 3) 相对脚本位置推测
"""
# 1. 环境变量
if "AIRPLAN_HOME" in __import__("os").environ:
return {"root": Path(__import__("os").environ["AIRPLAN_HOME"])}
# 2. ~/.airplan 默认
home = Path.home()
default = home / ".airplan"
if default.exists():
return {"root": default}
# 3. 尝试从当前脚本位置推测
# 脚本位于 {root}/scripts/airplan.py
import sys
script_root = Path(sys.argv[0]).resolve().parent if sys.argv else None
if script_root and (script_root.name == "scripts"):
plugin_root = script_root.parent
if (plugin_root / ".claude-plugin").exists():
return {"root": plugin_root}
# 4. 从本模块位置推测
# 本模块位于 {root}/lib/air_runtime/installer.py
module_root = Path(__file__).resolve().parent.parent.parent # lib/air_runtime/installer -> lib/air_runtime -> lib -> root
if (module_root / ".claude-plugin").exists():
return {"root": module_root}
raise FileNotFoundError("Cannot locate plugin installation directory")
def get_plugin_meta() -> dict:
"""读取 plugin.json 元数据"""
paths = resolve_plugin_paths()
meta_path = paths["root"] / ".claude-plugin" / "plugin.json"
if not meta_path.exists():
raise FileNotFoundError(f"plugin.json not found at {meta_path}")
return json.loads(meta_path.read_text())
def post_install_verify() -> dict:
"""
安装后验证:检查所有依赖工具是否存在。
返回 {tool: bool} 映射False 表示缺失。
"""
required_tools = [
"git", # 版本控制
"cmake", # 构建
"ffmpeg", # XDB 截图
"xvfb-run", # XDB 虚拟显示
]
result = {"ok": True, "missing": []}
for tool in required_tools:
found = shutil.which(tool) is not None
result[tool] = found
if not found:
result["ok"] = False
result["missing"].append(tool)
# 检查 Python 依赖
try:
import yaml
result["pyyaml"] = True
except ImportError:
result["pyyaml"] = False
result["ok"] = False
result["missing"].append("pyyaml")
# 检查目录结构
paths = resolve_plugin_paths()
for subdir in ["lib", "scripts", "skills", "commands"]:
p = paths["root"] / subdir
result[f"dir_{subdir}"] = p.exists()
if not p.exists():
result["ok"] = False
result["missing"].append(f"dir:{subdir}")
return result
def verify_plugin_json_paths() -> dict:
"""
验证 plugin.json 里的路径是否可解析。
设计原文 P0-9 指出硬编码 $HOME 是问题,这里修复它。
"""
meta = get_plugin_meta()
issues = []
# 检查 scripts 路径
entry = meta.get("entry", {})
if isinstance(entry, dict):
script_path_str = entry.get("args", [""])[0] if entry.get("args") else ""
else:
script_path_str = str(entry)
if "$HOME" in script_path_str:
# 尝试解析
resolved = script_path_str.replace("$HOME", str(Path.home()))
if not Path(resolved).exists():
issues.append(f"script path not found: {resolved}")
else:
# 修复:改用相对路径或 AIRPLAN_HOME 变量
issues.append(f"script uses $HOME: {script_path_str} (should use relative path)")
# 检查 marketplace 路径
market_path = meta.get("marketplace", "")
if "$HOME" in market_path:
issues.append(f"marketplace uses $HOME: {market_path}")
return {
"issues": issues,
"resolved_paths": resolve_plugin_paths(),
}

53
lib/air_runtime/io.py Normal file
View File

@@ -0,0 +1,53 @@
"""
原子 I/O 模块 — 消除 V1 5 份 _json_dump/_json_load 重复。
每次写入使用 tempfile + os.replace() 保证原子性,写入前自动备份。
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def atomic_json_write(path: Path, data: dict | list, indent: int = 2) -> None:
"""POSIX 原子写入tempfile + os.replace()。写入前自动备份旧文件为 .bak单级轮转"""
path.parent.mkdir(parents=True, exist_ok=True)
# 备份旧文件
if path.exists():
bak = path.with_suffix(path.suffix + ".bak")
try:
os.replace(str(path), str(bak))
except OSError as exc:
logger.warning("backup %s -> %s failed: %s", path, bak, exc)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
os.write(fd, json.dumps(data, indent=indent, ensure_ascii=False).encode("utf-8"))
os.close(fd)
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
def safe_json_load(path: Path) -> dict | list | None:
"""安全加载:处理损坏文件,自动从 .bak 恢复。文件不存在返回 None。"""
try:
return json.loads(path.read_text("utf-8"))
except FileNotFoundError:
return None
except (json.JSONDecodeError, UnicodeDecodeError):
bak = path.with_suffix(path.suffix + ".bak")
if bak.exists():
logger.warning("corrupt %s, restoring from %s", path, bak)
return json.loads(bak.read_text("utf-8"))
logger.error("corrupt %s with no backup", path)
return None

44
lib/air_runtime/lock.py Normal file
View File

@@ -0,0 +1,44 @@
"""
文件级并发控制 — 解决 V1 P0-4 零并发控制问题。
基于 fcntl.flock 的进程级文件锁,超时自动释放。
"""
from __future__ import annotations
import fcntl
import os
import time
from pathlib import Path
class FileLock:
"""基于 fcntl.flock(LOCK_EX | LOCK_NB) 的进程级文件锁"""
def __init__(self, path: Path, timeout: float = 10.0):
self._path = path.with_suffix(path.suffix + ".lock") if not path.suffix.endswith(".lock") else path
self._timeout = timeout
self._fd: int | None = None
def __enter__(self) -> FileLock:
self._fd = os.open(self._path, os.O_CREAT | os.O_RDWR)
deadline = time.monotonic() + self._timeout
while True:
try:
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except OSError:
if time.monotonic() >= deadline:
os.close(self._fd)
self._fd = None
raise TimeoutError(f"lock timeout after {self._timeout}s: {self._path}")
time.sleep(0.1)
def __exit__(self, *exc) -> None:
if self._fd is not None:
fcntl.flock(self._fd, fcntl.LOCK_UN)
os.close(self._fd)
self._fd = None
@property
def path(self) -> Path:
return self._path

View File

@@ -0,0 +1,11 @@
"""模式模块 init"""
from air_runtime.modes import arc_mode, eng_mode, do_mode, dbg_mode
from air_runtime.modes import xdb_mode, sdb_mode, ndb_mode
from air_runtime.modes import ctx_mode, dep_mode, tst_mode, sec_mode, rvr_mode
__all__ = [
"arc_mode", "eng_mode", "do_mode", "dbg_mode",
"xdb_mode", "sdb_mode", "ndb_mode",
"ctx_mode", "dep_mode", "tst_mode", "sec_mode", "rvr_mode",
]

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
AirArc mode — V2 架构规划器。
L1 代码级保障allowed-tools 限制为只读。
产出 execution-plan.json完整 DAG+ plan-delta.json增量重规划
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from air_runtime.contracts import now_iso
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, todo_path as get_todo_path
from air_runtime.review import build_parallel_review, render_review_markdown
from air_runtime.task_graph import TaskGraph, TaskNode, Edge, PlanDelta
from air_runtime.events import EventLog
from air_runtime.paths import event_log_path
from air_runtime.utils import ordered_unique
class ArcPhaseGate:
"""三阶段门控discussing → proposing → confirmed。
execution-plan.json 仅在 phase=confirmed 时允许写入。
"""
PHASES = ["discussing", "proposing", "confirmed"]
def __init__(self, state_path: Path):
self._state_path = state_path
@property
def current_phase(self) -> str:
data = safe_json_load(self._state_path) or {}
return data.get("arcPhase", "discussing")
def advance_to(self, phase: str) -> None:
if phase not in self.PHASES:
raise ValueError(f"invalid phase: {phase!r}")
idx_current = self.PHASES.index(self.current_phase)
idx_target = self.PHASES.index(phase)
if idx_target <= idx_current:
return
data = safe_json_load(self._state_path) or {}
data["arcPhase"] = phase
data[f"arcPhase_{phase}At"] = now_iso()
atomic_json_write(self._state_path, data)
def can_write_plan(self) -> bool:
return self.current_phase == "confirmed"
def confirm_architecture(self, user_confirmation: str) -> bool:
"""检查用户确认文本中的关键词,确认后推进到 confirmed。"""
confirm_keywords = ["确认", "可以", "同意", "confirm", "yes", "ok", "好的", "没问题"]
if any(kw in user_confirmation.lower() for kw in confirm_keywords):
self.advance_to("confirmed")
return True
return False
def _paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airarc"
return {
"root": root,
"state": root / "state.json",
"reviews_dir": root / "reviews",
"execution_plan_json": root / "reviews" / "execution-plan.json",
"execution_plan_md": root / "reviews" / "execution-plan.md",
"plan_delta_json": root / "reviews" / "plan-delta.json",
"task_graph_json": root / "reviews" / "task-graph.json",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
paths["reviews_dir"].mkdir(parents=True, exist_ok=True)
def _export_task_graph_json(graph: TaskGraph, path: Path) -> None:
data = {
"nodes": {nid: {"id": n.id, "status": n.status, "task": n.task,
"filesDirs": n.files_dirs, "doneWhen": n.done_when,
"inDegree": n.in_degree, "outEdges": n.out_edges,
"writeSet": n.write_set}
for nid, n in graph.nodes.items()},
"edges": [{"source": e.source, "target": e.target, "kind": e.kind} for e in graph.edges],
}
atomic_json_write(path, data)
def _build_graph_from_todo(todo_path: Path) -> TaskGraph:
"""从 todo.md 构建初始 DAG。"""
from air_runtime.todo_parser import parse_tasks
tasks = parse_tasks(todo_path)
graph = TaskGraph()
for t in tasks:
node = TaskNode(
id=t.task_id, status=t.status, task=t.task,
files_dirs=t.files_dirs, done_when=t.done_when,
)
graph.add_node(node)
return graph
def enter_mode(project_root: Path) -> dict:
paths = _paths(project_root)
_ensure_dirs(paths)
payload = {"enabled": True, "updatedAt": now_iso(), "projectRoot": str(project_root)}
atomic_json_write(paths["state"], payload)
return {"state_path": str(paths["state"])}
def parallel_review_mode(project_root: Path, todo_path: Path) -> dict:
paths = _paths(project_root)
_ensure_dirs(paths)
# 三阶段门控:仅在 confirmed 阶段允许写入 execution-plan.json
gate = ArcPhaseGate(paths["state"])
if not gate.can_write_plan():
return {
"blocked": True,
"reason": f"arc phase is '{gate.current_phase}', must be 'confirmed' before generating plan",
"currentPhase": gate.current_phase,
}
review = build_parallel_review(todo_path)
review_json_path = paths["reviews_dir"] / "parallel-review.json"
review_md_path = paths["reviews_dir"] / "parallel-review.md"
atomic_json_write(review_json_path, review.to_dict())
review_md_path.write_text(render_review_markdown(review), encoding="utf-8")
# 构建 DAG
graph = _build_graph_from_todo(todo_path)
for edge_info in review.to_dict().get("edges", []):
graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"],
kind=edge_info.get("kind", "dependency")))
_export_task_graph_json(graph, paths["task_graph_json"])
# 生成执行计划
selected_tasks = review.parallel_groups[0].task_ids if review.parallel_groups else []
execution_plan = {
"generatedAt": now_iso(), "projectRoot": str(project_root),
"todoPath": str(todo_path), "planSource": "airarc-post-plan-review",
"parallelReview": review.to_dict(),
"selectedTasks": selected_tasks,
"parallelGroups": [g.to_dict() for g in review.parallel_groups],
"conflicts": [c.to_dict() for c in review.conflicts],
"serializationPoints": review.serialization_points,
}
atomic_json_write(paths["execution_plan_json"], execution_plan)
markdown_lines = [
"# AirArc Execution Plan", "",
f"- Generated: `{execution_plan['generatedAt']}`",
f"- Plan Source: `{execution_plan['planSource']}`", "",
"## Selected Tasks",
]
for tid in selected_tasks:
markdown_lines.append(f"- `{tid}`")
markdown_lines.extend(["", "## Parallel Groups"])
for g in review.parallel_groups:
markdown_lines.append(f"- `{g.name}`: {', '.join(g.task_ids)}{g.reason}")
paths["execution_plan_md"].write_text("\n".join(markdown_lines) + "\n", encoding="utf-8")
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
"projectRoot": str(project_root)})
return {"json_path": str(review_json_path), "markdown_path": str(review_md_path),
"execution_plan_json_path": str(paths["execution_plan_json"]),
"parallel_group_count": len(review.parallel_groups),
"conflict_count": len(review.conflicts)}
def incremental_replan_mode(project_root: Path, todo_path: Path, previous_graph_path: Path | None = None) -> dict:
"""增量重规划:产出 PlanDelta 并喂回 prev graph再写 plan-delta.json。"""
paths = _paths(project_root)
_ensure_dirs(paths)
review = build_parallel_review(todo_path)
new_graph = _build_graph_from_todo(todo_path)
for edge_info in review.to_dict().get("edges", []):
new_graph.add_edge(Edge(source=edge_info["source"], target=edge_info["target"],
kind=edge_info.get("kind", "dependency")))
delta = PlanDelta()
if previous_graph_path and previous_graph_path.exists():
prev_graph = TaskGraph.load(previous_graph_path)
delta = new_graph.diff(prev_graph)
# 关键:把 delta 喂回去给 prev graph保留已调度状态
if previous_graph_path.exists():
prev_graph.apply_delta(delta)
_export_task_graph_json(prev_graph, previous_graph_path)
else:
delta.added_tasks = list(new_graph.nodes.values())
delta.edge_changes.added = list(new_graph.edges)
atomic_json_write(paths["plan_delta_json"], {
"generatedAt": now_iso(),
"removedTasks": delta.removed_tasks,
"addedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs,
"doneWhen": n.done_when, "writeSet": n.write_set}
for n in delta.added_tasks],
"modifiedTasks": [{"id": n.id, "task": n.task, "filesDirs": n.files_dirs,
"doneWhen": n.done_when, "writeSet": n.write_set}
for n in delta.modified_tasks],
"edgeChanges": {
"added": [{"source": e.source, "target": e.target, "kind": e.kind}
for e in delta.edge_changes.added],
"removed": [{"source": e.source, "target": e.target, "kind": e.kind}
for e in delta.edge_changes.removed],
},
})
_export_task_graph_json(new_graph, paths["task_graph_json"])
log = EventLog(event_log_path(project_root))
log.emit("arc.replanned", {"delta_added": len(delta.added_tasks),
"delta_removed": len(delta.removed_tasks),
"delta_modified": len(delta.modified_tasks),
"edges_added": len(delta.edge_changes.added),
"edges_removed": len(delta.edge_changes.removed)})
return {"plan_delta_json_path": str(paths["plan_delta_json"]),
"task_graph_json_path": str(paths["task_graph_json"]),
"added_count": len(delta.added_tasks),
"removed_count": len(delta.removed_tasks),
"modified_count": len(delta.modified_tasks),
"edges_added": len(delta.edge_changes.added),
"edges_removed": len(delta.edge_changes.removed)}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
if sub == "enter":
result = enter_mode(project_root)
print("airplan_mode=arc")
print(f"state_path={result['state_path']}")
elif sub == "parallel-review":
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
result = parallel_review_mode(project_root, tpath)
print("airplan_mode=arc")
print(f"json_path={result['json_path']}")
print(f"parallel_group_count={result['parallel_group_count']}")
print(f"conflict_count={result['conflict_count']}")
elif sub == "incremental-replan":
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
prev = _paths(project_root)["task_graph_json"]
result = incremental_replan_mode(project_root, tpath, prev)
print("airplan_mode=arc")
print(f"plan_delta_path={result['plan_delta_json_path']}")
print(f"added={result['added_count']} removed={result['removed_count']}")
else:
paths = _paths(project_root)
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=arc")
print(f"enabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,232 @@
"""
AirContext mode — V2 上下文管理器。
V2 改进:压缩质量校验、自适应 Token 估算、陈旧锁检测、三级降级压缩。
"""
from __future__ import annotations
import os
import re
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, CONTEXT_COMPACTED
from air_runtime.utils import now_iso
class CompressionLevel:
"""三级降级"""
RETRY = "retry" # 1. 重试一次
FALLBACK_MODEL = "fallback_model" # 2. 换模型
TRUNCATE = "truncate" # 3. 激进截断
DEFAULT_TRUNCATION_KEEP = 10 # 保留最近 10 轮
CHARS_PER_TOKEN = {
"chinese": 1.5,
"english": 4.0,
"code": 3.0,
"markup": 5.0,
}
MUST_PRESERVE_PATTERNS = [
r"[A-Za-z0-9_\-/]+\.(py|ts|js|cpp|h|md|json|yaml)",
r"ADR-\d{4}",
r"TODO|FIXME|HACK",
r"INV-\d+",
]
def _ctx_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "aircontext"
return {"root": root, "state": root / "state.json", "lock": root / "compactor.lock"}
def estimate_tokens(text: str) -> int:
chinese = len(re.findall(r"[一-鿿]", text))
code = len(re.findall(r"[{}()\[\];=<>]", text))
markup = len(re.findall(r"[#*\-`|]", text))
english = max(0, len(text) - chinese - code - markup)
tokens = (
chinese / CHARS_PER_TOKEN["chinese"]
+ code / CHARS_PER_TOKEN["code"]
+ markup / CHARS_PER_TOKEN["markup"]
+ english / CHARS_PER_TOKEN["english"]
)
return int(tokens)
def validate_compression(original: str, summary: str) -> dict:
missing = []
for pattern in MUST_PRESERVE_PATTERNS:
orig_matches = set(re.findall(pattern, original))
summary_matches = set(re.findall(pattern, summary))
lost = orig_matches - summary_matches
if len(lost) > len(orig_matches) * 0.3 and len(orig_matches) > 3:
missing.append({"pattern": pattern, "lost": list(lost)[:10]})
return {"ok": len(missing) == 0, "missing": missing, "originalTokens": estimate_tokens(original),
"summaryTokens": estimate_tokens(summary)}
def _compress_basic(text: str, max_tokens: int) -> str:
"""基础压缩token 估算 + 截断"""
estimated_tokens = len(text) // 3
if estimated_tokens <= max_tokens:
return text
# 按行截断
lines = text.split('\n')
chars_per_line_estimate = 30
keep_lines = int(max_tokens * chars_per_line_estimate / 80) # 80 chars/line
return '\n'.join(lines[-keep_lines:])
def _simplify_prompt(text: str) -> str:
"""简化 prompt移除详细上下文保留核心"""
lines = text.split('\n')
# 只保留前 3 行 + 包含 "def " / "class " / "#" 的行
kept = lines[:3]
kept.extend([l for l in lines[3:] if 'def ' in l or 'class ' in l or l.startswith('#')])
return '\n'.join(kept)
def compress_with_fallback(context: str, max_tokens: int = 4000) -> dict:
"""
三级降级压缩:
- 尝试正常压缩
- 失败则换模型重试
- 再失败则激进截断
返回: {"level": "...", "result": "...", "tokens": N}
"""
# Level 1: 正常尝试
try:
result = _compress_basic(context, max_tokens)
return {"level": CompressionLevel.RETRY, "result": result, "tokens": len(result.split())}
except Exception:
pass
# Level 2: 换模型(更简单的 prompt + 更宽松的 max_tokens
try:
simplified = _simplify_prompt(context)
result = _compress_basic(simplified, int(max_tokens * 1.5))
return {"level": CompressionLevel.FALLBACK_MODEL, "result": result, "tokens": len(result.split())}
except Exception:
pass
# Level 3: 激进截断
lines = context.split('\n')
# 提取 ADR 引用行
adr_lines = [l for l in lines if 'ADR-' in l or 'adr-' in l]
# 保留最近 N 轮
recent_lines = lines[-DEFAULT_TRUNCATION_KEEP * 5:] # 每轮约 5 行
truncated = '\n'.join(recent_lines + adr_lines)
return {
"level": CompressionLevel.TRUNCATE,
"result": truncated,
"tokens": len(truncated.split()),
"warning": f"truncated to {DEFAULT_TRUNCATION_KEEP * 5} recent lines + {len(adr_lines)} ADR lines"
}
def validate_compression_with_fallback(project_root: Path, context_path: Path) -> dict:
"""验证压缩有效性,失败时触发三级降级"""
content = context_path.read_text()
original_len = len(content)
# 先用当前配置尝试
result = compress_with_fallback(content)
validation = {
"original_chars": original_len,
"result_chars": len(result["result"]),
"level": result["level"],
"tokens": result.get("tokens", 0),
}
if result["level"] == CompressionLevel.TRUNCATE:
validation["warning"] = result.get("warning", "")
return validation
def acquire_compactor_lock(lock_path: Path) -> bool:
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
pid = int(lock_path.read_text().strip())
os.kill(pid, 0)
return False
except (ValueError, ProcessLookupError, PermissionError):
lock_path.unlink(missing_ok=True)
return acquire_compactor_lock(lock_path)
def ctx_enter(project_root: Path) -> dict:
paths = _ctx_paths(project_root)
paths["root"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
"projectRoot": str(project_root)})
return {"state_path": str(paths["state"])}
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
paths = _ctx_paths(project_root)
if sub == "enter":
result = ctx_enter(project_root)
print(f"airplan_mode=ctx\nstate_path={result['state_path']}")
elif sub == "estimate":
text = "sample" # 实际使用时从 stdin 或文件读取
tokens = estimate_tokens(text)
print(f"airplan_mode=ctx\ntokens={tokens}")
elif sub == "validate":
ctx_path = project_root / "AirPlan" / "context.md"
if ctx_path.exists():
validation = validate_compression_with_fallback(project_root, ctx_path)
ok = validation["level"] != CompressionLevel.TRUNCATE
print(f"airplan_mode=ctx\nvalidation_ok={ok}\nlevel={validation['level']}\ntokens={validation['tokens']}")
if "warning" in validation:
print(f"warning={validation['warning']}")
else:
print("airplan_mode=ctx\nvalidation_ok=true")
elif sub == "compress":
# 读取 context 文件
ctx_path = project_root / "AirPlan" / "context.md"
if not ctx_path.exists():
print("error: context.md not found")
return
# 调用三级降级压缩
content = ctx_path.read_text()
result = compress_with_fallback(content)
log = EventLog(event_log_path(project_root))
log.emit(CONTEXT_COMPACTED, {
"compressionLevel": result["level"],
"originalChars": len(content),
"resultChars": len(result["result"]),
"tokens": result.get("tokens", 0),
})
print(f"airplan_mode=ctx")
print(f"compression_level={result['level']}")
print(f"original_chars={len(content)}")
print(f"result_chars={len(result['result'])}")
if 'warning' in result:
print(f"warning={result['warning']}")
# 可选:写回压缩结果
if getattr(args, "write_back", False):
compressed_path = project_root / "AirPlan" / "context.compressed.md"
compressed_path.write_text(result['result'])
print(f"written_to={compressed_path}")
else:
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=ctx\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,279 @@
"""
AirDbg mode — V2 调试器。
V2 改进7步工作流强制追踪L1 代码级),修复前自动 git snapshot 回滚。
"""
from __future__ import annotations
import subprocess
from datetime import datetime, timezone
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, DEBUG_SESSION
from air_runtime.utils import now_iso, session_stamp
DBG_STEPS = [
"confirm_symptoms",
"load_context",
"reproduce",
"locate_root_cause",
"fix",
"verify",
"close_out",
]
class EvidenceFirstGate:
"""先读后写门控:未执行任何取证行为前,禁止代码修改。"""
EVIDENCE_TYPES = [
"screenshot",
"packet_capture",
"static_analysis",
"log_analysis",
"code_trace",
"reproduction",
]
def __init__(self, session_id: str):
self._session_id = session_id
self._collected_evidence: list[str] = []
def record_evidence(self, evidence_type: str, detail: str = "") -> None:
if evidence_type not in self.EVIDENCE_TYPES:
raise ValueError(f"unknown evidence type: {evidence_type!r}")
self._collected_evidence.append(evidence_type)
def can_modify_code(self) -> bool:
return len(self._collected_evidence) > 0
def gate_check(self) -> None:
if not self.can_modify_code():
raise WorkflowViolation(
"未执行任何取证行为,禁止修改代码。"
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
)
class WorkflowViolation(Exception):
"""调试工作流违规。"""
def _paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airdbg"
return {
"root": root,
"state": root / "state.json",
"sessions_dir": root / "sessions",
"snapshots_dir": root / "snapshots",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
for key in ("sessions_dir", "snapshots_dir"):
paths[key].mkdir(parents=True, exist_ok=True)
def start_session(project_root: Path, task_id: str) -> dict:
paths = _paths(project_root)
_ensure_dirs(paths)
session_id = f"{task_id}-{session_stamp()}"
session_state = {
"sessionId": session_id, "taskId": task_id,
"currentStep": "confirm_symptoms",
"startedAt": now_iso(),
"stepsCompleted": [],
"collectedEvidence": [],
"evidence": {},
"result": None,
}
session_path = paths["sessions_dir"] / f"{session_id}.json"
atomic_json_write(session_path, session_state)
log = EventLog(event_log_path(project_root))
log.emit(DEBUG_SESSION, {"sessionId": session_id, "taskId": task_id, "action": "started"})
return {
"sessionId": session_id, "sessionPath": str(session_path),
"currentStep": "confirm_symptoms",
"steps": DBG_STEPS,
}
def get_step(session_path: Path) -> str:
session = safe_json_load(session_path)
if not session or not isinstance(session, dict):
return "confirm_symptoms"
return session.get("currentStep", "confirm_symptoms")
def advance_step(session_path: Path, evidence: dict) -> str:
session = safe_json_load(session_path)
if not session or not isinstance(session, dict):
raise ValueError("invalid session")
current = session.get("currentStep", "confirm_symptoms")
current_idx = DBG_STEPS.index(current) if current in DBG_STEPS else 0
# 验证当前步骤需要的证据
required_evidence = _required_evidence_for_step(current)
if required_evidence:
missing = [k for k in required_evidence if k not in evidence]
if missing:
raise ValueError(f"step '{current}' requires evidence: {missing}")
# 先读后写门控fix 步骤前必须已有取证记录
if current == "fix":
collected = session.get("collectedEvidence", [])
if not collected:
raise WorkflowViolation(
"未执行任何取证行为,禁止修改代码。"
"请先至少完成以下一项:截图、抓包、静态分析、日志分析、代码追踪、复现步骤。"
)
session["stepsCompleted"].append({"step": current, "evidence": evidence, "completedAt": now_iso()})
# 累积取证记录confirm_symptoms, load_context, reproduce, locate_root_cause 都是取证步骤)
evidence_steps = {"confirm_symptoms", "load_context", "reproduce", "locate_root_cause"}
if current in evidence_steps:
session.setdefault("collectedEvidence", []).append(current)
next_idx = current_idx + 1
if next_idx < len(DBG_STEPS):
session["currentStep"] = DBG_STEPS[next_idx]
atomic_json_write(session_path, session)
return session["currentStep"]
def skip_reproduce(session_path: Path, reason: str) -> str:
session = safe_json_load(session_path)
if not session or not isinstance(session, dict):
raise ValueError("invalid session")
if session.get("currentStep") != "reproduce":
raise ValueError("can only skip from reproduce step")
session["currentStep"] = "locate_root_cause"
session["stepsCompleted"].append({"step": "reproduce", "evidence": {"skipped": True, "reason": reason}})
atomic_json_write(session_path, session)
return "locate_root_cause"
def pre_fix_snapshot(project_root: Path, task_id: str) -> str:
"""
修复前创建 git tag 作为回滚点。
V2 改进:只提交当前 task 写集范围内的文件(在 result.filesChanged 中声明)。
"""
# 1. 读取 worker result 获取 filesChanged
result_path = project_root / "AirPlan" / "state" / "airdo" / "tasks" / task_id / "result.json"
if not result_path.exists():
# 无 result 文件,回退到全量提交(但加 warning
return _snapshot_full(project_root, task_id)
result = safe_json_load(result_path) or {}
files_changed = result.get("filesChanged", [])
if not files_changed:
# 无写集声明,回退到当前工作目录中的已跟踪文件
files_changed = None
# 2. 只 add 这些文件,然后 commit
return _snapshot_selective(project_root, task_id, files_changed)
def _snapshot_selective(project_root: Path, task_id: str, files: list[str] | None) -> str:
"""只提交指定的文件列表"""
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
try:
# git add <files>
if files:
for f in files:
fp = project_root / f
if fp.exists():
subprocess.run(["git", "-C", str(project_root), "add", str(fp)],
check=True, capture_output=True, timeout=10)
# 如果有 staging 的内容则 commit否则跳过避免空 commit
result = subprocess.run(
["git", "-C", str(project_root), "commit", "-m",
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
subprocess.run(
["git", "-C", str(project_root), "tag", ref],
check=True, capture_output=True, text=True, timeout=10,
)
return ref
else:
# 没有 staged 内容或 commit 失败
return ""
except subprocess.CalledProcessError:
return ""
def _snapshot_full(project_root: Path, task_id: str) -> str:
"""全量提交(仅在无 filesChanged 信息时的 fallback加 warning"""
import logging
logger = logging.getLogger(__name__)
logger.warning("pre_fix_snapshot: no filesChanged info, falling back to git commit -am")
# 这里保留原逻辑但加 comment 说明这是 fallback
return _do_git_commit_am(project_root, task_id)
def _do_git_commit_am(project_root: Path, task_id: str) -> str:
"""原始实现,保留用于 fallback"""
ref = f"airdbg-prefix-{task_id}-{session_stamp()}"
try:
subprocess.run(
["git", "-C", str(project_root), "commit", "-am",
f"AirDbg pre-fix snapshot: {task_id}", "--allow-empty"],
check=True, capture_output=True, text=True, timeout=30,
)
subprocess.run(
["git", "-C", str(project_root), "tag", ref],
check=True, capture_output=True, text=True, timeout=10,
)
except subprocess.CalledProcessError:
return ""
return ref
def _required_evidence_for_step(step: str) -> list[str]:
evidence_map = {
"confirm_symptoms": ["symptom", "expected", "actual"],
"load_context": [],
"reproduce": ["reproduction_steps"],
"locate_root_cause": ["root_cause_analysis"],
"fix": ["fix_description", "files_changed"],
"verify": ["validation_result"],
"close_out": ["residual_risk", "adr_updates"],
}
return evidence_map.get(step, [])
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
paths = _paths(project_root)
_ensure_dirs(paths)
if sub == "start":
result = start_session(project_root, args.task_id)
print("airplan_mode=dbg")
print(f"session_id={result['sessionId']}")
print(f"current_step={result['currentStep']}")
print(f"steps={','.join(result['steps'])}")
elif sub == "snapshot":
ref = pre_fix_snapshot(project_root, args.task_id)
print("airplan_mode=dbg")
print(f"snapshot_ref={ref}")
else:
state = safe_json_load(paths["state"]) or {}
print("airplan_mode=dbg")
print(f"enabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,39 @@
"""AirDep mode — V2 部署器。"""
from pathlib import Path
from air_runtime.deploy_runtime import deploy, DeployTarget
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, DEPLOY_COMPLETED
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id
sub = args.sub or "deploy"
if sub == "deploy" and args.host:
target = DeployTarget(host=args.host)
binary = Path(args.binary).expanduser().resolve() if args.binary else Path(".")
result = deploy(tid, project_root, target, binary, tid)
log = EventLog(event_log_path(project_root))
log.emit(DEPLOY_COMPLETED, {
"taskId": tid,
"success": result.success,
"host": args.host,
"binaryMd5": result.binary_md5,
"serviceStatus": result.service_status,
})
print("airplan_mode=dep")
print(f"task_id={tid}")
print(f"success={result.success}")
print(f"md5={result.binary_md5}")
print(f"service_status={result.service_status}")
if result.error:
print(f"error={result.error}")
else:
paths = airplan_root(project_root) / "state" / "airdep"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=dep\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,165 @@
"""
AirDo mode — V2 任务执行器。
V2 改进:强制 AirDbg 路由L1 代码级task_id 注入防护。
"""
from __future__ import annotations
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
from air_runtime.contracts import WorkerResult, now_iso
from air_runtime.utils import sanitize_task_id, session_stamp
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) -> dict:
tid = sanitize_task_id(task_id)
paths = _paths(project_root, tid)
_ensure_dirs(paths)
worker_state = {
"taskId": tid, "status": "implementing",
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
}
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})
return {
"taskId": tid, "briefPath": str(paths["brief"]),
"handoffPath": str(paths["handoff"]),
"resultPath": str(paths["result"]),
"workerStatePath": str(paths["worker_state"]),
}
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
"""V2 核心改进:强制 AirDbg 路由。"""
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
# V2 L1 代码级done 但无证据 → 强制 AirDbg
if status == "done":
if not result.validations and not result.files_changed:
routing_decision = {
"target": "airdbg",
"reason": "done without evidence — mandatory debug review",
"forced": True,
}
else:
routing_decision = {"target": "merge", "forced": False}
# V2 L1 代码级blocked/failed → 强制 AirDbg
elif status in ("blocked", "failed"):
routing_decision = {
"target": "airdbg",
"reason": f"status={status} — AirDbg mandatory before return",
"forced": True,
}
else:
routing_decision = {"target": "merge", "forced": False}
# 持久化
finalized = result.to_dict()
finalized["routingDecision"] = routing_decision
finalized["finalizedAt"] = now_iso()
atomic_json_write(paths["result"], finalized)
atomic_json_write(paths["worker_state"], {"taskId": tid, "status": "finished",
"resultPath": str(paths["result"]),
"routingDecision": routing_decision})
log = EventLog(event_log_path(project_root))
log.emit("task.finished", {"taskId": tid, "status": status,
"routingTarget": routing_decision["target"]})
# emit task.completed / task.blocked based on final status
if status == "done":
log.emit(TASK_COMPLETED, {"taskId": tid, "routingTarget": routing_decision["target"]})
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"]),
"routingDecision": routing_decision,
}
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":
result = enter_worker(project_root, tid)
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)
print("airplan_mode=do")
print(f"task_id={finalized['taskId']}")
print(f"status={finalized['status']}")
print(f"routing_target={finalized['routingDecision']['target']}")
print(f"routing_forced={finalized['routingDecision']['forced']}")

View File

@@ -0,0 +1,577 @@
"""
AirEng mode — V2 调度引擎。
L1 代码级保障硬编码轮询循环、Worker 超时、资源压力检测、事务化合并。
"""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.lock import FileLock
from air_runtime.paths import (
airplan_root, todo_path as get_todo_path, engine_state_path,
event_log_path, plan_path, agents_path,
)
from air_runtime.events import EventLog, TASK_DISPATCHED, TASK_COMPLETED, TASK_BLOCKED, MERGE_STARTED, MERGE_COMPLETED, \
INTERVENTION_STALL, ENGINE_CYCLE, WORKER_TIMEOUT, REPAIR_CREATED, REPAIR_RESOLVED
from air_runtime.evidence_gate import EvidenceGatePolicy, EvidenceClass
from air_runtime.modes.merge_pipeline import (
apply_document_updates,
enforce_doc_sync_requirements,
sync_engine_managed_docs,
update_todo_after_merge,
)
from air_runtime.task_graph import TaskGraph
from air_runtime.todo_parser import parse_tasks
from air_runtime.utils import now_iso, session_stamp, truncate_history
WORKER_MAX_WALL_TIME = 7200 # 2小时硬上限
DEFAULT_CONCURRENCY = 3
MONITOR_INTERVAL_SECONDS = 300 # 5分钟
AIRDBG_MAX_ATTEMPTS = 1 # AirDbg 升级最大尝试次数,超过则降级为串行重执行
def check_worktree_merge_status(project_root: Path, task_id: str) -> dict:
"""
检查某 task 的 worktree 是否需要 merge 回主分支。
如果 merge 失败conflicts自动升级到 AirDbg。
再失败则降级为串行重执行。
返回: {"status": "ok" | "upgraded_to_airdbg" | "downgraded_to_serial", ...}
"""
from air_runtime.worktree import WorktreeIsolation
wt_path = project_root / ".git" / "worktrees" / f"air-{task_id}"
if not wt_path.exists():
return {"status": "ok"} # 无 worktree正常
# 尝试 merge 回主分支
wt = WorktreeIsolation(repo_root=project_root)
result = wt.merge_back(task_id, wt_path)
if result.successful:
# merge 成功,清理 worktree
wt.cleanup(task_id, wt_path)
return {"status": "ok", "conflicts": []}
# merge 失败 → 升级到 AirDbg
from air_runtime.modes.dbg_mode import start_session
session = start_session(project_root, task_id)
return {
"status": "upgraded_to_airdbg",
"taskId": task_id,
"conflicts": result.conflicts,
"sessionId": session.get("sessionId"),
}
def _paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "aireng"
return {
"root": root,
"state": root / "state.json",
"dispatch_dir": root / "dispatch",
"archive_dir": root / "archive",
"plan_dir": root / "plans",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
for key in ("dispatch_dir", "archive_dir", "plan_dir"):
paths[key].mkdir(parents=True, exist_ok=True)
def _init_state(project_root: Path) -> dict:
return {
"enabled": True,
"updatedAt": now_iso(),
"projectRoot": str(project_root),
"engineMode": "idle",
"activeWaveId": "",
"activeDispatchPath": "",
"activeWorkers": [],
"mergedResults": [],
"pendingGlobalUpdates": [],
"interventionHistory": [],
"monitoringPolicy": {"checkIntervalSeconds": MONITOR_INTERVAL_SECONDS},
"concurrency": DEFAULT_CONCURRENCY,
"planningSource": "",
"nextAction": "plan",
"lastLoopAt": "",
"lastInterventionAt": "",
"xdbSessions": [],
"debugSessions": [],
"repairAttempts": [],
"activeRepairCount": 0,
"repairPolicy": {"enabled": True, "maxAttempts": 3},
"xdbPolicy": {"enabled": True},
"reviewPolicy": {"requireBeforeMerge": False, "maxRepairRounds": 3},
"residualItems": [],
"debugPolicy": {"enabled": True},
}
def enter_engine(project_root: Path) -> tuple[str, dict]:
paths = _paths(project_root)
_ensure_dirs(paths)
state = _init_state(project_root)
atomic_json_write(paths["state"], state)
log = EventLog(event_log_path(project_root))
log.emit("engine.entered")
return str(paths["state"]), {}
def status_engine(project_root: Path) -> dict:
paths = _paths(project_root)
return safe_json_load(paths["state"]) or _init_state(project_root)
def build_engine_plan(project_root: Path, todo_path: Path) -> dict:
paths = _paths(project_root)
_ensure_dirs(paths)
arc_reviews = airplan_root(project_root) / "state" / "airarc" / "reviews"
plan_json = arc_reviews / "execution-plan.json"
task_graph_json = arc_reviews / "task-graph.json"
planning_source = "engine-fallback"
plan_data: dict = {}
if plan_json.exists():
loaded = safe_json_load(plan_json)
if loaded and isinstance(loaded, dict):
plan_data = loaded
planning_source = "airarc-execution-plan"
if not plan_data:
tasks = parse_tasks(todo_path)
plan_data = {
"selectedTasks": [t.task_id for t in tasks if t.status == "TODO"],
"parallelGroups": [],
}
plan_path = paths["plan_dir"] / f"{session_stamp()}.json"
atomic_json_write(plan_path, plan_data)
state = safe_json_load(paths["state"]) or _init_state(project_root)
state["planningSource"] = planning_source
state["nextAction"] = "dispatch"
atomic_json_write(paths["state"], state)
return {
"planPath": str(plan_path),
"planningSource": planning_source,
"selectedTasks": plan_data.get("selectedTasks", []),
"parallelGroupCount": len(plan_data.get("parallelGroups", [])),
"taskGraphPath": str(task_graph_json),
"planJson": plan_data,
}
def dispatch_worker_group(project_root: Path, group_name: str = "") -> dict:
"""派发 worker 组,含区域冲突检测。"""
paths = _paths(project_root)
_ensure_dirs(paths)
# P0 修复:每次派发前检查 todo.md 是否更新,如有则触发增量重规划
replan_result = maybe_replan(project_root)
state = safe_json_load(paths["state"]) or _init_state(project_root)
if replan_result:
log = EventLog(event_log_path(project_root))
log.emit("eng.replan.triggered", {
"added": replan_result.get("added_count", 0),
"removed": replan_result.get("removed_count", 0),
"modified": replan_result.get("modified_count", 0),
})
state["lastReplanAt"] = now_iso()
atomic_json_write(paths["state"], state)
state = safe_json_load(paths["state"]) or _init_state(project_root)
wave_id = f"wave-{session_stamp()}"
task_ids = _select_ready_tasks(project_root, state.get("concurrency", DEFAULT_CONCURRENCY))
if not task_ids:
return {"dispatchPath": "", "groupName": group_name, "waveId": wave_id,
"taskIds": [], "recommendedConcurrency": 0}
# 区域冲突检测:多个任务时检查写集重叠
dispatch_metadata: dict | None = None
if len(task_ids) > 1:
from air_runtime.worktree import RegionConflictDetector, ConflictLevel
todo_path = get_todo_path(project_root)
tasks = parse_tasks(todo_path)
task_write_sets = {
t.task_id: [f.strip() for f in t.files_dirs.split(",") if f.strip()]
for t in tasks if t.task_id in task_ids
}
if task_write_sets:
detector = RegionConflictDetector()
conflicts = detector.detect_batch(task_write_sets)
hard_blocked = [c for c in conflicts if c.level == ConflictLevel.HARD]
if hard_blocked:
# HARD 冲突:强制串行,只派第一个
task_ids = task_ids[:1]
dispatch_metadata = {
"forcedSerialization": True,
"reason": f"HARD conflict: {hard_blocked[0].task_a} <-> {hard_blocked[0].task_b}",
}
else:
soft_conflicts = [c for c in conflicts if c.level == ConflictLevel.SOFT]
if soft_conflicts:
dispatch_metadata = {
"worktreeIsolation": True,
"softConflicts": [c.to_dict() for c in soft_conflicts],
}
dispatch_payload = {
"waveId": wave_id, "groupName": group_name,
"taskIds": task_ids,
"createdAt": now_iso(),
"recommendedConcurrency": min(len(task_ids), state.get("concurrency", DEFAULT_CONCURRENCY)),
}
dispatch_path = paths["dispatch_dir"] / f"{wave_id}.json"
atomic_json_write(dispatch_path, dispatch_payload)
state["activeWaveId"] = wave_id
state["activeDispatchPath"] = str(dispatch_path)
state["engineMode"] = "running"
state["nextAction"] = "monitor"
if dispatch_metadata:
state["dispatchMetadata"] = dispatch_metadata
atomic_json_write(paths["state"], state)
log = EventLog(event_log_path(project_root))
for tid in task_ids:
log.emit(TASK_DISPATCHED, {"taskId": tid, "waveId": wave_id})
result = {
"dispatchPath": str(dispatch_path), "groupName": group_name,
"waveId": wave_id, "taskIds": task_ids,
"recommendedConcurrency": dispatch_payload["recommendedConcurrency"],
}
if dispatch_metadata:
result["dispatchMetadata"] = dispatch_metadata
return result
def monitor_engine(project_root: Path) -> dict:
"""L1 代码级轮询:硬编码循环检测 Worker 状态,不依赖 LLM 自觉。"""
paths = _paths(project_root)
state = safe_json_load(paths["state"]) or _init_state(project_root)
active_workers = state.get("activeWorkers", [])
stalled_count = 0
ready_to_merge = 0
interventions = []
for worker in active_workers:
worker_state_path = Path(worker.get("workerStatePath", ""))
age = (datetime.now(timezone.utc) - datetime.fromisoformat(worker.get("spawnedAt", now_iso()))).total_seconds()
# 超时检测
if age > WORKER_MAX_WALL_TIME:
interventions.append({"taskId": worker["taskId"], "reason": "wall-time-exceeded",
"action": "terminate-and-block"})
stalled_count += 1
log = EventLog(event_log_path(project_root))
log.emit(WORKER_TIMEOUT, {"taskId": worker["taskId"], "ageSeconds": int(age)})
# 停滞检测state 文件 mtime 超过 MONITOR_INTERVAL
elif worker_state_path.exists():
mtime = worker_state_path.stat().st_mtime
if time.time() - mtime > MONITOR_INTERVAL_SECONDS:
interventions.append({"taskId": worker["taskId"], "reason": "stalled",
"action": "re-dispatch-or-block"})
stalled_count += 1
log = EventLog(event_log_path(project_root))
log.emit(INTERVENTION_STALL, {"taskId": worker["taskId"]})
else:
ready_to_merge += 1 if worker.get("status") == "done" else 0
# 资源压力检测
try:
load = os.getloadavg()[0]
cpu_count = os.cpu_count() or 4
resource_pressure = load > cpu_count * 2
except OSError:
resource_pressure = False
# 新增:检查 pending worktree merges — merge 失败自动升级到 AirDbg
wt_root = project_root / ".git" / "worktrees"
if wt_root.exists():
for wt_dir in wt_root.iterdir():
if wt_dir.is_dir() and wt_dir.name.startswith("air-"):
task_id = wt_dir.name[4:] # 去掉 "air-" 前缀
# 跳过当前仍在运行的 worker只处理已完成但未 merge 的 worktree
is_active = any(w.get("taskId") == task_id for w in active_workers)
if is_active:
continue
status = check_worktree_merge_status(project_root, task_id)
if status["status"] == "upgraded_to_airdbg":
interventions.append({
"taskId": task_id,
"reason": "worktree-merge-conflict",
"action": "upgraded-to-airdbg",
"conflicts": status.get("conflicts", []),
"sessionId": status.get("sessionId"),
})
log = EventLog(event_log_path(project_root))
log.emit("worktree.merge.conflict", {
"taskId": task_id,
"action": "upgraded-to-airdbg",
"sessionId": status.get("sessionId"),
})
elif status["status"] == "downgraded_to_serial":
interventions.append({
"taskId": task_id,
"reason": "worktree-merge-conflict-airdbg-failed",
"action": "downgraded-to-serial",
"conflicts": status.get("conflicts", []),
})
log = EventLog(event_log_path(project_root))
log.emit("worktree.merge.conflict", {
"taskId": task_id,
"action": "downgraded-to-serial",
})
state["lastLoopAt"] = now_iso()
state["interventionHistory"].extend(interventions)
# 将升级到 AirDbg 的 session 记入 state.debugSessions
for iv in interventions:
if iv.get("action") == "upgraded-to-airdbg" and iv.get("sessionId"):
state.setdefault("debugSessions", []).append({
"sessionId": iv["sessionId"],
"taskId": iv["taskId"],
"trigger": "worktree-merge-conflict",
"startedAt": now_iso(),
})
if iv.get("action") == "downgraded-to-serial":
state.setdefault("repairAttempts", []).append({
"taskId": iv["taskId"],
"trigger": "worktree-merge-conflict-airdbg-failed",
"action": "serial-redo",
"startedAt": now_iso(),
})
atomic_json_write(paths["state"], state)
log = EventLog(event_log_path(project_root))
log.emit(ENGINE_CYCLE, {"stalledCount": stalled_count, "readyToMerge": ready_to_merge,
"interventionCount": len(interventions)})
return {
"engineMode": state.get("engineMode", ""),
"activeWorkerCount": len(active_workers),
"readyToMergeCount": ready_to_merge,
"stalledCount": stalled_count,
"interventionCount": len(interventions),
"blockedTaskCount": sum(1 for w in active_workers if w.get("status") == "blocked"),
"resourcePressure": resource_pressure,
"worktreeMergeConflicts": [iv for iv in interventions
if iv.get("reason", "").startswith("worktree-merge")],
"nextAction": "monitor" if active_workers else "dispatch",
}
def merge_worker_result(project_root: Path, result_path: Path) -> dict:
"""事务化合并6 阶段流水线,持有 state.json 锁。"""
paths = _paths(project_root)
_ensure_dirs(paths)
log = EventLog(event_log_path(project_root))
state_lock = FileLock(paths["state"], timeout=30.0)
todo_lock = FileLock(get_todo_path(project_root), timeout=10.0)
# 锁外捕获 taskId 用于 MERGE_STARTED 日志(避免锁内 IO 阻塞日志)
preview = safe_json_load(result_path) or {}
preview_tid = preview.get("taskId", "") if isinstance(preview, dict) else ""
log.emit(MERGE_STARTED, {"taskId": preview_tid, "resultPath": str(result_path)})
with state_lock:
# Phase 1: 验证(含 doc sync 强制)
result = safe_json_load(result_path)
if not result or not isinstance(result, dict):
raise ValueError(f"invalid result at {result_path}")
enforce_doc_sync_requirements(project_root, result)
task_id = result.get("taskId", "")
status = result.get("status", "")
# Phase 1.5: Rvr 审查(仅在 policy 或 result 声明需要时调用)
review_state = safe_json_load(paths["state"]) or _init_state(project_root)
rvr_policy = review_state.get("reviewPolicy", {"requireBeforeMerge": False})
if rvr_policy.get("requireBeforeMerge") or result.get("requireReview"):
from air_runtime.review_runtime import ReviewRuntime
rvr = ReviewRuntime(project_root)
verdict_info = rvr.get_verdict_for_task(task_id)
verdict = verdict_info.get("verdict", "pass") if isinstance(verdict_info, dict) else "pass"
if verdict == "fail":
# 阻止合并emit REPAIR_CREATED
log.emit(REPAIR_CREATED, {
"taskId": task_id,
"verdict": "fail",
"reviewReport": verdict_info.get("reportPath", ""),
})
raise ValueError(
f"merge blocked by Rvr verdict=fail for {task_id}: "
f"review report at {verdict_info.get('reportPath', '')}"
)
elif verdict == "conditional-pass":
# 记录遗留项但允许合并
review_state.setdefault("residualItems", []).append({
"taskId": task_id,
"verdict": "conditional-pass",
"residual": verdict_info.get("residual", []),
"mergedAt": now_iso(),
})
# 写回 state 以便后续 Phase 6 看到
atomic_json_write(paths["state"], review_state)
# pass 走原流程
# Phase 2: 归档(可重试 — 失败重抛由调用方决定)
stamp = session_stamp()
archive_path = paths["archive_dir"] / f"{task_id}-{stamp}.json"
atomic_json_write(archive_path, result)
# Phase 3: 应用文档更新(原子写入)
applied = apply_document_updates(project_root, result)
# Phase 4: 同步引擎管理文档(原子写入)
sync_paths = sync_engine_managed_docs(project_root, result, applied)
# Phase 5: 更新 todo嵌套 FileLock
with todo_lock:
update_todo_after_merge(project_root, result, applied, sync_paths)
# Phase 6: 更新引擎状态(原子写入)
state = safe_json_load(paths["state"]) or _init_state(project_root)
state["mergedResults"].append({
"taskId": task_id,
"status": status,
"archivedAt": now_iso(),
"archivePath": str(archive_path),
"appliedDocs": [str(p) for p in applied],
"syncedDocs": [str(p) for p in sync_paths],
})
state["mergedResults"] = truncate_history(state["mergedResults"], max_items=100)
state["activeWorkers"] = [
w for w in state.get("activeWorkers", []) if w.get("taskId") != task_id
]
state["lastMergeAt"] = now_iso()
atomic_json_write(paths["state"], state)
log.emit(MERGE_COMPLETED, {
"taskId": task_id,
"status": status,
"archivePath": str(archive_path),
"appliedDocCount": len(applied),
"syncedDocCount": len(sync_paths),
})
# emit task completed/blocked based on merge status
if status == "done":
log.emit(TASK_COMPLETED, {"taskId": task_id, "archivePath": str(archive_path)})
elif status in ("blocked", "failed"):
log.emit(TASK_BLOCKED, {"taskId": task_id, "status": status})
# repair resolved on successful merge after previous repair
repair_attempts = state.get("repairAttempts", [])
if repair_attempts and any(r.get("taskId") == task_id for r in repair_attempts):
log.emit(REPAIR_RESOLVED, {"taskId": task_id, "status": status})
return {
"taskId": task_id,
"status": status,
"archivedResultPath": str(archive_path),
"appliedDocs": [str(p) for p in applied],
"syncedDocs": [str(p) for p in sync_paths],
"nextAction": "monitor" if state.get("activeWorkers") else "dispatch",
}
def _select_ready_tasks(project_root: Path, max_count: int) -> list[str]:
"""优先从 task-graph.json 的 DAG 计算 in-degree 为 0 的 TODO taskfallback parse_tasks。"""
task_graph_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
if task_graph_json.exists():
try:
graph = TaskGraph.load(task_graph_json)
ready = graph.ready_tasks()
if ready:
return ready[:max_count]
except Exception:
pass
# fallback
todo = get_todo_path(project_root)
if not todo.exists():
return []
tasks = parse_tasks(todo)
return [t.task_id for t in tasks if t.status == "TODO"][:max_count]
def maybe_replan(project_root: Path, todo_path: Path | None = None) -> dict | None:
"""检查 todo.md mtime vs task_graph.json mtime若 todo 更新则触发 replan。"""
from air_runtime.modes.arc_mode import incremental_replan_mode
todo = todo_path or get_todo_path(project_root)
tg = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
if not tg.exists():
return None
if not todo.exists():
return None
if todo.stat().st_mtime <= tg.stat().st_mtime:
return None
return incremental_replan_mode(project_root, todo, tg)
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
if sub == "enter":
state_path, _ = enter_engine(project_root)
print("airplan_mode=eng")
print(f"state_path={state_path}")
elif sub == "status":
state = status_engine(project_root)
print(f"airplan_mode=eng")
print(f"enabled={state.get('enabled', False)}")
print(f"engine_mode={state.get('engineMode', '')}")
print(f"active_workers={len(state.get('activeWorkers', []))}")
print(f"merged_results={len(state.get('mergedResults', []))}")
print(f"next_action={state.get('nextAction', '')}")
elif sub == "plan":
tpath = Path(args.todo).expanduser().resolve() if args.todo else get_todo_path(project_root)
result = build_engine_plan(project_root, tpath)
print("airplan_mode=eng")
print(f"planning_source={result['planningSource']}")
print(f"selected_tasks={','.join(result['selectedTasks'])}")
elif sub == "dispatch":
result = dispatch_worker_group(project_root, args.dispatch_group)
print("airplan_mode=eng")
print(f"wave_id={result['waveId']}")
print(f"task_ids={','.join(result['taskIds'])}")
print(f"dispatch_path={result['dispatchPath']}")
elif sub == "monitor":
result = monitor_engine(project_root)
print("airplan_mode=eng")
print(f"active_workers={result['activeWorkerCount']}")
print(f"ready_to_merge={result['readyToMergeCount']}")
print(f"stalled={result['stalledCount']}")
print(f"interventions={result['interventionCount']}")
print(f"worktree_merge_conflicts={len(result.get('worktreeMergeConflicts', []))}")
print(f"next_action={result['nextAction']}")
elif sub == "merge":
result_path = Path(args.result).expanduser().resolve()
merged = merge_worker_result(project_root, result_path)
print("airplan_mode=eng")
print(f"task_id={merged['taskId']}")
print(f"status={merged['status']}")
print(f"next_action={merged['nextAction']}")

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']}")

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

View File

@@ -0,0 +1 @@
from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main

View File

@@ -0,0 +1,32 @@
"""AirRvr mode — V2 需求审查器。"""
from pathlib import Path
from air_runtime.review_runtime import ReviewRuntime, ReviewReport, RequirementCoverage
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id
sub = args.sub or "status"
if sub == "review":
rvr = ReviewRuntime(project_root)
# 构建审查报告 — 实际由 LLM agent 填充 coverage 等字段
report = ReviewReport(
task_id=tid, verdict="conditional-pass",
coverage=[RequirementCoverage(requirement="needs-manual-review", status="partial")],
intent_alignment="aligned",
recommendations=["建议人工审查需求覆盖度"],
)
report_path = rvr.save_report(report)
verdict = rvr.get_integration_verdict(report)
print("airplan_mode=rvr")
print(f"task_id={tid}")
print(f"verdict={verdict}")
print(f"report_path={report_path}")
else:
paths = airplan_root(project_root) / "state" / "airrvr"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=rvr\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,63 @@
"""AirSDB mode — V2 静态分析器模式。
多后端静态分析 (cppcheck / clang-tidy / clippy / go-vet / tsc)
以及 diff 模式(对比两次扫描结果)。
"""
from __future__ import annotations
from pathlib import Path
from air_runtime.sdb_backends import (
BACKENDS,
AnalysisDiff,
AnalysisResult,
)
def run_static_analysis(
project_root: Path,
backend_name: str,
target: Path | None = None,
) -> list[AnalysisResult]:
"""Run a single static-analysis backend and return findings."""
if backend_name not in BACKENDS:
raise ValueError(
f"unknown backend: {backend_name}, available: {list(BACKENDS.keys())}"
)
return BACKENDS[backend_name].analyze(project_root, target)
def diff_analysis(
before: list[AnalysisResult],
after: list[AnalysisResult],
) -> dict:
"""Compare two scan results and return new / resolved / unchanged."""
return AnalysisDiff().diff(before, after)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
backend = getattr(args, "backend", None) or "cppcheck"
target = Path(args.target).expanduser().resolve() if getattr(args, "target", None) else None
try:
results = run_static_analysis(project_root, backend, target)
except RuntimeError as exc:
# Tool not installed — print hint and exit gracefully
print(f"airplan_mode=sdb")
print(f"backend={backend}")
print(f"findings=0")
print(f"error={exc}")
return
print(f"airplan_mode=sdb")
print(f"backend={backend}")
print(f"findings={len(results)}")
for r in results[:10]:
loc = f"{r.file}:{r.line}" if r.line is not None else r.file
print(f"{loc}: {r.severity}: {r.message}")

View File

@@ -0,0 +1,62 @@
"""AirSec mode — V2 安全扫描器。"""
import sys
from pathlib import Path
from air_runtime.sec_runtime import scan_file, scan_file_with_mode, scan_result_data, ScanMode, ScanReport
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, SEC_SCAN
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id or "unknown"
sub = args.sub or "scan"
mode = getattr(args, "sec_mode", "blocking") or "blocking"
if mode not in ("advisory", "blocking"):
print("error: mode must be advisory or blocking", file=sys.stderr)
sys.exit(1)
if sub == "scan":
if args.scan_path:
scan_path = Path(args.scan_path).expanduser().resolve()
if scan_path.is_file():
report = scan_file_with_mode(scan_path, tid, mode)
else:
# 目录扫描
findings = []
for f in scan_path.rglob("*"):
if f.is_file() and not any(x in f.name for x in [".git", "node_modules", "__pycache__"]):
r = scan_file_with_mode(f, tid, mode)
findings.extend(r.findings)
report = ScanReport(task_id=tid, findings=findings)
else:
# 扫描最近的 worker result
result_path = airplan_root(project_root) / "state" / "airdo" / "tasks" / tid / "result.json"
data = safe_json_load(result_path) or {}
report = scan_result_data(data, tid)
log = EventLog(event_log_path(project_root))
log.emit(SEC_SCAN, {
"taskId": tid,
"clean": report.clean,
"findings": len(report.findings),
"whitelisted": report.whitelisted,
"mode": mode,
})
print("airplan_mode=sec")
print(f"task_id={tid}")
print(f"scan_path={getattr(args, 'scan_path', '')}")
print(f"mode={mode}")
print(f"clean={report.clean}")
print(f"findings={len(report.findings)}")
print(f"whitelisted={report.whitelisted}")
if report.findings:
for f in report.findings[:5]:
print(f" {f.file}:{f.line} [{f.severity}] {f.rule}: {f.match}")
else:
paths = airplan_root(project_root) / "state" / "airsec"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=sec\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,35 @@
"""AirTst mode — V2 测试运行器。"""
from pathlib import Path
from air_runtime.test_runtime import TestRunner
from air_runtime.io import safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, TEST_RUN
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
tid = args.task_id
sub = args.sub or "status"
if sub == "run" and args.framework:
runner = TestRunner()
result = runner.run(tid, project_root, args.framework)
log = EventLog(event_log_path(project_root))
log.emit(TEST_RUN, {
"taskId": tid,
"framework": result.framework,
"total": result.total,
"passed": result.passed,
"failed": result.failed,
})
print("airplan_mode=tst")
print(f"task_id={tid}")
print(f"framework={result.framework}")
print(f"total={result.total} passed={result.passed} failed={result.failed}")
else:
paths = airplan_root(project_root) / "state" / "airtst"
state = safe_json_load(paths / "state.json") or {}
print(f"airplan_mode=tst\nenabled={state.get('enabled', False)}")

View File

@@ -0,0 +1,44 @@
"""AirXDB mode -- GUI verification via screenshot capture."""
from __future__ import annotations
from pathlib import Path
from air_runtime.xdb_capture import CaptureManager, CaptureResult
from air_runtime.events import EventLog, XDB_CAPTURED
from air_runtime.paths import event_log_path
def capture_screenshot(
project_root: Path,
output_name: str = "screenshot.png",
prefer: str = "auto",
) -> CaptureResult:
out_path = project_root / "AirPlan" / "state" / "airxdb" / "captures" / output_name
out_path.parent.mkdir(parents=True, exist_ok=True)
mgr = CaptureManager()
result = mgr.capture(out_path, prefer)
log = EventLog(event_log_path(project_root))
log.emit(XDB_CAPTURED, {
"outputName": output_name,
"success": result.success,
"method": result.method,
"outputPath": str(result.output_path),
})
return result
def main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
prefer = getattr(args, "prefer", "auto")
output = getattr(args, "output", None) or "screenshot.png"
result = capture_screenshot(project_root, output, prefer)
print("airplan_mode=xdb")
print(f"success={result.success}")
print(f"method={result.method}")
print(f"output={result.output_path}")
if result.error:
print(f"error={result.error}")

View File

@@ -0,0 +1,97 @@
"""AirXDB mode — V2 GUI调试器AirSDB mode — V2 静态分析AirNDB mode — V2 网络调试。"""
# --- AirXDB ---
from __future__ import annotations
import json
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import airplan_root
from air_runtime.utils import now_iso
def _xdb_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airxdb"
return {"root": root, "state": root / "state.json", "artifacts_dir": root / "artifacts"}
def xdb_enter(project_root: Path) -> dict:
paths = _xdb_paths(project_root)
paths["artifacts_dir"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso(),
"projectRoot": str(project_root)})
return {"state_path": str(paths["state"])}
def xdb_status(project_root: Path) -> dict:
from air_runtime.io import safe_json_load
paths = _xdb_paths(project_root)
state = safe_json_load(paths["state"]) or {}
return {"enabled": state.get("enabled", False)}
def xdb_main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
if sub == "enter":
result = xdb_enter(project_root)
print(f"airplan_mode=xdb\nstate_path={result['state_path']}")
else:
s = xdb_status(project_root)
print(f"airplan_mode=xdb\nenabled={s['enabled']}")
# --- AirSDB ---
def _sdb_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airsdb"
return {"root": root, "state": root / "state.json", "reports_dir": root / "reports"}
def sdb_enter(project_root: Path) -> dict:
paths = _sdb_paths(project_root)
paths["reports_dir"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
return {"state_path": str(paths["state"])}
def sdb_main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
from air_runtime.io import safe_json_load
paths = _sdb_paths(project_root)
if sub == "enter":
result = sdb_enter(project_root)
print(f"airplan_mode=sdb\nstate_path={result['state_path']}")
else:
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=sdb\nenabled={state.get('enabled', False)}")
# --- AirNDB ---
def _ndb_paths(project_root: Path) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airndb"
return {"root": root, "state": root / "state.json", "captures_dir": root / "captures"}
def ndb_enter(project_root: Path) -> dict:
paths = _ndb_paths(project_root)
paths["captures_dir"].mkdir(parents=True, exist_ok=True)
atomic_json_write(paths["state"], {"enabled": True, "updatedAt": now_iso()})
return {"state_path": str(paths["state"])}
def ndb_main(args) -> None:
project_root = Path(args.project).expanduser().resolve()
sub = args.sub or "status"
from air_runtime.io import safe_json_load
paths = _ndb_paths(project_root)
if sub == "enter":
result = ndb_enter(project_root)
print(f"airplan_mode=ndb\nstate_path={result['state_path']}")
else:
state = safe_json_load(paths["state"]) or {}
print(f"airplan_mode=ndb\nenabled={state.get('enabled', False)}")

99
lib/air_runtime/paths.py Normal file
View File

@@ -0,0 +1,99 @@
"""
路径约定 — V2 统一所有子模块的 AirPlan 目录结构。
"""
from __future__ import annotations
from pathlib import Path
def airplan_root(project_root: Path) -> Path:
return project_root / "AirPlan"
def state_root(project_root: Path) -> Path:
return airplan_root(project_root) / "state"
def todo_path(project_root: Path) -> Path:
return airplan_root(project_root) / "todo.md"
def plan_path(project_root: Path) -> Path:
return airplan_root(project_root) / "plan.md"
def agents_path(project_root: Path) -> Path:
return airplan_root(project_root) / "AGENTS.md"
def docs_root(project_root: Path) -> Path:
return airplan_root(project_root) / "docs"
# --- 子模块状态路径 ---
def engine_state_path(project_root: Path) -> Path:
return state_root(project_root) / "aireng" / "state.json"
def worker_state_path(project_root: Path, task_id: str) -> Path:
return state_root(project_root) / "airdo" / "tasks" / task_id / "worker-state.json"
def arc_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airarc" / "state.json"
def dbg_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airdbg" / "state.json"
def xdb_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airxdb" / "state.json"
def sdb_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airsdb" / "state.json"
def ndb_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airndb" / "state.json"
def ctx_state_path(project_root: Path) -> Path:
return state_root(project_root) / "aircontext" / "state.json"
def dep_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airdep" / "state.json"
def tst_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airtst" / "state.json"
def sec_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airsec" / "state.json"
def rvr_state_path(project_root: Path) -> Path:
return state_root(project_root) / "airrvr" / "state.json"
def event_log_path(project_root: Path) -> Path:
return state_root(project_root) / "events.jsonl"
def task_graph_state_path(project_root: Path) -> Path:
return state_root(project_root) / "task-graph.json"
def required_project_artifacts() -> list[str]:
return [
"AirPlan/AGENTS.md",
"AirPlan/plan.md",
"AirPlan/todo.md",
"AirPlan/docs/architecture/adr/",
"AirPlan/docs/architecture/c4/module.md",
]

View File

@@ -0,0 +1,59 @@
"""
项目引导模块 — 确保 AirPlan 目录结构存在。
V2 保持与 V1 相同的不变量:制品驱动通信、上下文隔离。
"""
from __future__ import annotations
from pathlib import Path
def ensure_project_bootstrap(project_root: Path) -> dict[str, bool]:
"""创建 AirPlan 必需目录结构。"""
root = project_root / "AirPlan"
docs = root / "docs"
arch = docs / "architecture"
adr_dir = arch / "adr"
c4_dir = arch / "c4"
debug_dir = docs / "debug"
state = root / "state"
dirs = [
root,
docs,
arch,
adr_dir,
c4_dir,
debug_dir,
state,
state / "airarc" / "reviews",
state / "aireng" / "dispatch",
state / "aireng" / "archive",
state / "aireng" / "plans",
state / "airdo" / "tasks",
state / "airdbg" / "sessions",
state / "airdbg" / "snapshots",
state / "airxdb" / "artifacts",
state / "airsdb" / "reports",
state / "airndb" / "captures",
state / "aircontext",
state / "airdep" / "sessions",
state / "airtst" / "reports",
state / "airsec",
state / "airrvr" / "reviews",
]
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
# 创建必要文件
(root / "AGENTS.md").touch()
(root / "plan.md").touch()
(root / "todo.md").touch()
(adr_dir / "placeholder.md").touch()
(c4_dir / "module.md").touch()
(debug_dir / "debug-log.md").touch()
(debug_dir / "gui-debug-log.md").touch()
(docs / "staticanalysis.md").touch()
return {"bootstrap": True}

126
lib/air_runtime/review.py Normal file
View File

@@ -0,0 +1,126 @@
"""
并行审查模块 — V2 从 V1 迁移。
分析任务依赖、写集冲突、产出并行组和串行点。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.todo_parser import parse_tasks
@dataclass
class ParallelGroup:
name: str
task_ids: list[str]
reason: str = ""
def to_dict(self) -> dict:
return {"name": self.name, "task_ids": self.task_ids, "reason": self.reason}
@dataclass
class Conflict:
task_a: str
task_b: str
reason: str = ""
def to_dict(self) -> dict:
return {"task_a": self.task_a, "task_b": self.task_b, "reason": self.reason}
@dataclass
class ReviewResult:
parallel_groups: list[ParallelGroup] = field(default_factory=list)
conflicts: list[Conflict] = field(default_factory=list)
serialization_points: list[dict] = field(default_factory=list)
edges: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"parallelGroups": [{"name": g.name, "task_ids": g.task_ids, "reason": g.reason} for g in self.parallel_groups],
"conflicts": [{"task_a": c.task_a, "task_b": c.task_b, "reason": c.reason} for c in self.conflicts],
"serializationPoints": self.serialization_points,
"edges": self.edges,
}
@classmethod
def from_dict(cls, data: dict) -> ReviewResult:
return cls(
parallel_groups=[ParallelGroup(**g) for g in data.get("parallelGroups", [])],
conflicts=[Conflict(**c) for c in data.get("conflicts", [])],
serialization_points=data.get("serializationPoints", []),
edges=data.get("edges", []),
)
def build_parallel_review(todo_path: Path) -> ReviewResult:
"""分析 todo.md产出并行组和冲突。"""
tasks = parse_tasks(todo_path)
result = ReviewResult()
# 解析依赖task 文本中的 "依赖 T-xxx" 或 Done When 中的引用
edges = []
for t in tasks:
deps = re.findall(r"T-\d+[a-z]*", t.done_when)
deps.extend(re.findall(r"依赖\s+(T-\d+[a-z]*)", t.task))
for dep in deps:
if dep != t.task_id:
edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
result.edges.append({"source": dep, "target": t.task_id, "kind": "dependency"})
# 写集冲突检测
file_map: dict[str, list[str]] = {}
for t in tasks:
if t.files_dirs:
files = [f.strip() for f in t.files_dirs.split(",")]
for f in files:
file_map.setdefault(f, []).append(t.task_id)
conflicts = []
for fpath, tid_list in file_map.items():
for i, tid_a in enumerate(tid_list):
for tid_b in tid_list[i + 1:]:
conflicts.append(Conflict(tid_a, tid_b, f"shared file: {fpath}"))
result.conflicts = conflicts
# 串行点:同文件不同任务的依赖链
for fpath, tid_list in file_map.items():
if len(tid_list) > 1:
for tid in tid_list[1:]:
result.serialization_points.append({
"taskId": tid,
"reasons": [f"serialized with {tid_list[0]} due to shared file: {fpath}"],
})
# 并行组:入度为 0 的任务
target_count = {e["target"] for e in edges}
ready = [t.task_id for t in tasks if t.task_id not in target_count and t.status == "TODO"]
if ready:
result.parallel_groups.append(ParallelGroup(
name="wave-1", task_ids=ready,
reason="no dependencies on other TODO tasks",
))
return result
def render_review_markdown(review: ReviewResult) -> str:
lines = ["# AirArc Parallel Review", ""]
lines.append(f"## Summary")
lines.append(f"- Parallel groups: {len(review.parallel_groups)}")
lines.append(f"- Conflicts: {len(review.conflicts)}")
lines.append(f"- Serialization points: {len(review.serialization_points)}")
lines.append("")
lines.append("## Parallel Groups")
for g in review.parallel_groups:
lines.append(f"### {g.name}")
lines.append(f"Reason: {g.reason}")
lines.append(f"Tasks: {', '.join(g.task_ids)}")
lines.append("")
lines.append("## Conflicts")
for c in review.conflicts:
lines.append(f"- {c.task_a} <-> {c.task_b}: {c.reason}")
return "\n".join(lines)

View File

@@ -0,0 +1,148 @@
"""
AirRvr 需求审查运行时 — V2 新增组件。
基于原始需求文档对已完成任务进行独立审查,验证交付物与需求的一致性。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import rvr_state_path
from air_runtime.utils import session_stamp
@dataclass
class RequirementCoverage:
requirement: str
status: str # covered | partial | missing
evidence: str = ""
@dataclass
class CodeToDesignItem:
design_item: str
implementation_status: str # aligned | divergent | missing
code_location: str = ""
design_location: str = ""
divergence_detail: str = ""
@dataclass
class ReviewReport:
task_id: str
verdict: str # pass | conditional-pass | fail
coverage: list[RequirementCoverage] = field(default_factory=list)
intent_alignment: str = "aligned" # aligned | divergent
divergence_notes: str = ""
regression_risk: str = "none" # none | low | medium | high
code_quality: dict = field(default_factory=lambda: {
"complexity": "low", "readability": "good", "duplication": "none", "error_handling": "complete",
})
lifecycle_health: dict = field(default_factory=lambda: {
"resource_leak": "none", "connection_management": "proper",
"timeout_strategy": "present", "retry_strategy": "present",
})
runtime_stability: dict = field(default_factory=lambda: {
"crash_risk": "none", "race_condition": "none",
"memory_leak": "none", "user_impact": "none",
})
code_to_design_table: list[CodeToDesignItem] = field(default_factory=list)
logging_checks: dict = field(default_factory=lambda: {
"spdlog_integrated": False,
"non_standard_logging": [],
"debug_release_switch": False,
"critical_path_logging": False,
"unified_format": False,
})
recommendations: list[str] = field(default_factory=list)
class ReviewRuntime:
"""AirRvr 审查运行时 — 管理审查会话和报告持久化。"""
REVIEW_MODES = ["per-task", "per-wave", "per-milestone"]
def __init__(self, project_root: Path):
self._project_root = project_root
self._state_dir = rvr_state_path(project_root).parent
self._reviews_dir = self._state_dir / "reviews"
self._reviews_dir.mkdir(parents=True, exist_ok=True)
def save_report(self, report: ReviewReport) -> Path:
report_path = self._reviews_dir / f"{report.task_id}-{session_stamp()}.json"
atomic_json_write(report_path, self._report_to_dict(report))
return report_path
def load_report(self, task_id: str, timestamp: str) -> ReviewReport | None:
from air_runtime.io import safe_json_load
report_path = self._reviews_dir / f"{task_id}-{timestamp}.json"
data = safe_json_load(report_path)
if data:
return self._dict_to_report(data)
return None
def get_integration_verdict(self, report: ReviewReport) -> str:
"""与 AirEng 集成pass → 允许合并conditional-pass → 合并但记录遗留项fail → 阻止合并。"""
return report.verdict
def get_verdict_for_task(self, task_id: str) -> dict:
"""从持久化的 review report 读 verdict返回 dict 含 verdict/residual/reportPath。
没有 report 时返回 {"verdict": "pass", "reportPath": ""}(默认放行)。"""
from air_runtime.io import safe_json_load
# reports/ 是 AirEng 约定的存放路径(验证脚本和 eng_mode 期望的位置)
reports_dir = self._state_dir / "reports"
report_path = reports_dir / f"{task_id}.json"
if not report_path.exists():
# 兼容旧路径 reviews/ 下的 {task_id}-{ts}.json找最新一份
alt = self._reviews_dir
if alt.exists():
candidates = sorted(alt.glob(f"{task_id}-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
if candidates:
report_path = candidates[0]
if not report_path.exists():
return {"verdict": "pass", "reportPath": "", "residual": []}
report = safe_json_load(report_path)
if not report or not isinstance(report, dict):
return {"verdict": "pass", "reportPath": str(report_path), "residual": []}
return {
"verdict": report.get("verdict", "pass"),
"residual": report.get("residual", []),
"reportPath": str(report_path),
"summary": report.get("summary", ""),
}
@staticmethod
def _report_to_dict(report: ReviewReport) -> dict:
return {
"taskId": report.task_id,
"verdict": report.verdict,
"coverage": [c.__dict__ for c in report.coverage],
"intentAlignment": report.intent_alignment,
"divergenceNotes": report.divergence_notes,
"regressionRisk": report.regression_risk,
"codeQuality": report.code_quality,
"lifecycleHealth": report.lifecycle_health,
"runtimeStability": report.runtime_stability,
"codeToDesignTable": [c.__dict__ for c in report.code_to_design_table],
"loggingChecks": report.logging_checks,
"recommendations": report.recommendations,
}
@staticmethod
def _dict_to_report(data: dict) -> ReviewReport:
return ReviewReport(
task_id=data.get("taskId", ""),
verdict=data.get("verdict", "fail"),
coverage=[RequirementCoverage(**c) for c in data.get("coverage", [])],
intent_alignment=data.get("intentAlignment", "aligned"),
divergence_notes=data.get("divergenceNotes", ""),
regression_risk=data.get("regressionRisk", "none"),
code_quality=data.get("codeQuality", {}),
lifecycle_health=data.get("lifecycleHealth", {}),
runtime_stability=data.get("runtimeStability", {}),
code_to_design_table=[CodeToDesignItem(**c) for c in data.get("codeToDesignTable", [])],
logging_checks=data.get("loggingChecks", {}),
recommendations=data.get("recommendations", []),
)

View File

@@ -0,0 +1,527 @@
"""AirSDB backends — 5 static analyzer backends + AnalysisDiff.
Backends:
CppcheckBackend — C/C++ via cppcheck
ClangTidyBackend — C/C++ via clang-tidy
RustClippyBackend — Rust via cargo clippy
GoVetBackend — Go via go vet + staticcheck
TypeScriptBackend — TypeScript via tsc --noEmit
"""
from __future__ import annotations
import json
import shutil
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------------------
# Unified result type
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class AnalysisResult:
tool: str
file: str
line: int | None
column: int | None
severity: str # error | warning | info
message: str
rule_id: str | None = None
# ---------------------------------------------------------------------------
# Abstract base
# ---------------------------------------------------------------------------
class StaticAnalyzerBackend(ABC):
"""Abstract base for every static-analysis backend."""
@abstractmethod
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
"""Run the analyzer and return structured findings."""
@property
@abstractmethod
def name(self) -> str:
"""Short identifier for this backend (e.g. 'cppcheck')."""
@property
@abstractmethod
def install_hint(self) -> str:
"""Human-readable hint shown when the tool is not installed."""
# -- helpers available to all backends ---------------------------------
def _check_tool(self, tool_cmd: str) -> None:
"""Raise RuntimeError if *tool_cmd* is not on PATH."""
if not shutil.which(tool_cmd):
raise RuntimeError(self.install_hint)
@staticmethod
def _run(cmd: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess:
"""Run *cmd* and capture stdout/stderr. Returns CompletedProcess."""
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
timeout=300,
)
# ---------------------------------------------------------------------------
# CppcheckBackend
# ---------------------------------------------------------------------------
class CppcheckBackend(StaticAnalyzerBackend):
"""C/C++ static analysis via cppcheck."""
name = "cppcheck"
install_hint = (
"cppcheck is not installed. "
"Install it with: sudo apt install cppcheck (Debian/Ubuntu) "
"or: brew install cppcheck (macOS)"
)
# Template: file:line:column:severity:id:message
_TEMPLATE = "{file}:{line}:{column}:{severity}:{id}:{message}"
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("cppcheck")
src = str(target) if target else str(project_root)
cmd = [
"cppcheck",
"--quiet",
f"--template={self._TEMPLATE}",
"--force",
src,
]
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
for line in proc.stderr.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":", 5)
if len(parts) < 6:
continue
try:
ln = int(parts[1]) if parts[1].strip() else None
except ValueError:
ln = None
try:
col = int(parts[2]) if parts[2].strip() else None
except ValueError:
col = None
severity = parts[3].strip()
# Map cppcheck severities to our unified set
if severity not in ("error", "warning", "info"):
if severity in ("performance", "portability", "style"):
severity = "warning"
else:
severity = "info"
results.append(AnalysisResult(
tool=self.name,
file=parts[0].strip(),
line=ln,
column=col,
severity=severity,
message=parts[5].strip(),
rule_id=parts[4].strip() or None,
))
return results
# ---------------------------------------------------------------------------
# ClangTidyBackend
# ---------------------------------------------------------------------------
class ClangTidyBackend(StaticAnalyzerBackend):
"""C/C++ static analysis via clang-tidy."""
name = "clang-tidy"
install_hint = (
"clang-tidy is not installed. "
"Install it with: sudo apt install clang-tidy (Debian/Ubuntu) "
"or: brew install llvm (macOS, then use llvm/bin/clang-tidy)"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("clang-tidy")
src = str(target) if target else str(project_root)
cmd = [
"clang-tidy",
"--quiet",
src,
]
# Use compile_commands.json if present
comp_db = project_root / "compile_commands.json"
if comp_db.exists():
cmd.append(f"-p={comp_db.parent}")
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
# clang-tidy output format: <file>:<line>:<col>: warning: <message> [check-name]
for line in proc.stderr.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":", 3)
if len(parts) < 4:
continue
try:
ln = int(parts[1].strip()) if parts[1].strip() else None
except ValueError:
ln = None
try:
col = int(parts[2].strip()) if parts[2].strip() else None
except ValueError:
col = None
msg_part = parts[3].strip()
severity = "warning"
# Detect "error:" prefix
if msg_part.startswith("error:"):
severity = "error"
msg_part = msg_part[len("error:"):].strip()
elif msg_part.startswith("warning:"):
msg_part = msg_part[len("warning:"):].strip()
elif msg_part.startswith("note:"):
severity = "info"
msg_part = msg_part[len("note:"):].strip()
# Extract [check-name] at the end
rule_id = None
if msg_part.endswith("]"):
bracket = msg_part.rfind("[")
if bracket != -1:
rule_id = msg_part[bracket + 1:-1].strip()
msg_part = msg_part[:bracket].strip()
results.append(AnalysisResult(
tool=self.name,
file=parts[0].strip(),
line=ln,
column=col,
severity=severity,
message=msg_part,
rule_id=rule_id,
))
return results
# ---------------------------------------------------------------------------
# RustClippyBackend
# ---------------------------------------------------------------------------
class RustClippyBackend(StaticAnalyzerBackend):
"""Rust static analysis via cargo clippy."""
name = "clippy"
install_hint = (
"cargo clippy is not available. "
"Install Rust toolchain: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh "
"then: rustup component add clippy"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("cargo")
cmd = [
"cargo", "clippy",
"--message-format=json",
]
# If a specific target file/dir is given, we still run cargo clippy
# on the whole crate (cargo does not support single-file analysis).
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("reason") != "compiler-message":
continue
msg = obj.get("message", {})
level = msg.get("level", "")
if level == "error":
severity = "error"
elif level in ("warning",):
severity = "warning"
else:
severity = "info"
for span in msg.get("spans", []):
results.append(AnalysisResult(
tool=self.name,
file=span.get("file_name", ""),
line=span.get("line_start"),
column=span.get("column_start"),
severity=severity,
message=msg.get("message", ""),
rule_id=msg.get("code", {}).get("code") or None,
))
# If no JSON output (e.g. compile error), also parse stderr
if not results and proc.stderr:
for line in proc.stderr.splitlines():
line = line.strip()
if "error" in line.lower() and ":" in line:
results.append(AnalysisResult(
tool=self.name,
file=str(project_root),
line=None,
column=None,
severity="error",
message=line,
rule_id=None,
))
return results
# ---------------------------------------------------------------------------
# GoVetBackend
# ---------------------------------------------------------------------------
class GoVetBackend(StaticAnalyzerBackend):
"""Go static analysis via go vet + staticcheck."""
name = "go-vet"
install_hint = (
"go is not installed. "
"Install Go: https://go.dev/dl/ "
"For staticcheck: go install honnef.co/go/tools/cmd/staticcheck@latest"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
self._check_tool("go")
results: list[AnalysisResult] = []
# 1) go vet — JSON output
vet_cmd = ["go", "vet", "./..."]
proc = self._run(vet_cmd, cwd=project_root)
if proc.stderr:
results.extend(self._parse_go_vet_output(proc.stderr))
# 2) staticcheck (optional — don't fail if not installed)
if shutil.which("staticcheck"):
sc_cmd = ["staticcheck", "-f=json", "./..."]
sc_proc = self._run(sc_cmd, cwd=project_root)
for line in sc_proc.stdout.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
severity = "warning"
if obj.get("severity", "") == "error":
severity = "error"
results.append(AnalysisResult(
tool="staticcheck",
file=obj.get("location", {}).get("file", ""),
line=obj.get("location", {}).get("line"),
column=obj.get("location", {}).get("column"),
severity=severity,
message=obj.get("message", ""),
rule_id=obj.get("code", ""),
))
return results
@staticmethod
def _parse_go_vet_output(text: str) -> list[AnalysisResult]:
"""Parse go vet stderr output.
go vet output format (non-JSON):
<file>:<line>: <message>
"""
results: list[AnalysisResult] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":", 2)
if len(parts) < 3:
continue
try:
ln = int(parts[1].strip()) if parts[1].strip() else None
except ValueError:
ln = None
results.append(AnalysisResult(
tool="go vet",
file=parts[0].strip(),
line=ln,
column=None,
severity="warning",
message=parts[2].strip(),
rule_id=None,
))
return results
# ---------------------------------------------------------------------------
# TypeScriptBackend
# ---------------------------------------------------------------------------
class TypeScriptBackend(StaticAnalyzerBackend):
"""TypeScript static analysis via tsc --noEmit."""
name = "tsc"
install_hint = (
"tsc (TypeScript compiler) is not installed. "
"Install it with: npm install -g typescript "
"or add it to your project: npm install --save-dev typescript"
)
def analyze(self, project_root: Path, target: Path | None = None) -> list[AnalysisResult]:
# tsc can be installed locally (npx) or globally
tsc_cmd = self._find_tsc()
if tsc_cmd is None:
raise RuntimeError(self.install_hint)
cmd = tsc_cmd + ["--noEmit", "--pretty", "false"]
proc = self._run(cmd, cwd=project_root)
results: list[AnalysisResult] = []
# tsc output format: <file>(<line>,<col>): error TS<code>: <message>
for line in proc.stdout.splitlines():
line = line.strip()
if not line:
continue
results.append(self._parse_tsc_line(line))
return results
def _find_tsc(self) -> list[str] | None:
"""Return the tsc command as a list, or None if not found."""
if shutil.which("tsc"):
return ["tsc"]
if shutil.which("npx"):
return ["npx", "tsc"]
return None
@staticmethod
def _parse_tsc_line(line: str) -> AnalysisResult:
"""Parse a single tsc diagnostic line.
Format: <file>(<line>,<col>): error TS1234: <message>
"""
severity = "error"
rule_id = None
# Split on the first colon-space after the position paren
# e.g. "src/foo.ts(10,5): error TS2322: Type 'string' ..."
main_parts = line.split(": ", 1)
location_part = main_parts[0] if main_parts else line
message = main_parts[1].strip() if len(main_parts) > 1 else ""
# Extract file, line, column from "file(line,col)"
file_part = location_part
ln = None
col = None
paren = location_part.rfind("(")
if paren != -1 and location_part.endswith(")"):
file_part = location_part[:paren]
pos_str = location_part[paren + 1:-1]
pos_parts = pos_str.split(",", 1)
try:
ln = int(pos_parts[0].strip()) if pos_parts[0].strip() else None
except ValueError:
pass
if len(pos_parts) > 1:
try:
col = int(pos_parts[1].strip()) if pos_parts[1].strip() else None
except ValueError:
pass
# Extract severity + rule from " error TS2322" in the remainder
if len(main_parts) > 1:
# The part between the first colon-space and the message
# is in the original line — re-parse
rest = line[len(location_part) + 2:] # after ": "
if rest.startswith("error "):
severity = "error"
rest = rest[len("error "):]
elif rest.startswith("warning "):
severity = "warning"
rest = rest[len("warning "):]
# rest now starts with "TS1234: message"
ts_parts = rest.split(": ", 1)
if ts_parts:
rule_id = ts_parts[0].strip() or None
if len(ts_parts) > 1:
message = ts_parts[1].strip()
return AnalysisResult(
tool="tsc",
file=file_part,
line=ln,
column=col,
severity=severity,
message=message,
rule_id=rule_id,
)
# ---------------------------------------------------------------------------
# AnalysisDiff
# ---------------------------------------------------------------------------
class AnalysisDiff:
"""Compare two lists of AnalysisResult and classify findings as
new, resolved, or unchanged."""
@staticmethod
def _key(r: AnalysisResult) -> tuple[str, int | None, str | None]:
"""Dedup key: (file, line, rule_id)."""
return (r.file, r.line, r.rule_id)
def diff(
self,
before: list[AnalysisResult],
after: list[AnalysisResult],
) -> dict:
before_keys = {self._key(r): r for r in before}
after_keys = {self._key(r): r for r in after}
before_set = set(before_keys.keys())
after_set = set(after_keys.keys())
new_keys = after_set - before_set
resolved_keys = before_set - after_set
unchanged_keys = before_set & after_set
return {
"new": [after_keys[k] for k in new_keys],
"resolved": [before_keys[k] for k in resolved_keys],
"unchanged": [before_keys[k] for k in unchanged_keys],
}
# ---------------------------------------------------------------------------
# Convenience registry
# ---------------------------------------------------------------------------
BACKENDS: dict[str, StaticAnalyzerBackend] = {
"cppcheck": CppcheckBackend(),
"clang-tidy": ClangTidyBackend(),
"clippy": RustClippyBackend(),
"go-vet": GoVetBackend(),
"tsc": TypeScriptBackend(),
}

View File

@@ -0,0 +1,175 @@
"""
AirSec 安全扫描运行时 — V2 新增组件。
制品敏感数据扫描 + 自动脱敏 + 误报白名单 + 确认流程 + advisory/blocking 模式。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
SECRET_PATTERNS: list[tuple[str, str]] = [
("api_key", r'(?:api[_-]?key|apikey)\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?'),
("aws_key", r'AKIA[0-9A-Z]{16}'),
("private_key", r'-----BEGIN (?:RSA|EC|DSA|OPENSSH) PRIVATE KEY-----'),
("token", r'(?:token|secret|password)\s*[:=]\s*["\']?([^\s"\']{8,})["\']?'),
("jwt", r'eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+'),
("url_credential", r'https?://[^:@]+:([^@]+)@'),
]
ALLOWLIST_PATTERNS: list[str] = [
r'EXAMPLE',
r'example',
r'YOUR_API_KEY',
r'TODO',
r'<your-',
r'placeholder',
r'xxxxxxxx',
]
# 文件名白名单:命中则跳过该文件的所有发现
WHITELIST_FILE_PATTERNS: list[str] = [
r"\.example\.",
r"^test_",
r"^mock_",
r"_test\.py$",
r"\.fixture\.",
]
# 用户确认记录(首次发现需确认,后续同模式自动放行)
USER_CONFIRMATIONS: dict[str, dict] = {} # {fingerprint: {pattern, confirmed_at, user}}
class ScanMode:
"""扫描模式advisory只报告不阻止/ blocking阻止合并"""
ADVISORY = "advisory"
BLOCKING = "blocking"
@dataclass
class ScanFinding:
rule: str
file: str
line: int
match: str # 截断显示,不包含完整密钥
severity: str = "high" # high | medium | low
@dataclass
class ScanReport:
task_id: str
findings: list[ScanFinding] = field(default_factory=list)
whitelisted: int = 0
clean: bool = True
advisory_blocked: bool = False
def scan_file(file_path: Path, task_id: str = "") -> ScanReport:
findings: list[ScanFinding] = []
whitelisted = 0
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return ScanReport(task_id=task_id, clean=True)
for rule, pattern in SECRET_PATTERNS:
for match in re.finditer(pattern, content, re.IGNORECASE):
matched_text = match.group(0)
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
whitelisted += 1
continue
line_no = content[:match.start()].count("\n") + 1
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
findings.append(ScanFinding(
rule=rule, file=str(file_path), line=line_no, match=display,
))
return ScanReport(
task_id=task_id,
findings=findings,
whitelisted=whitelisted,
clean=len(findings) == 0,
)
def scan_result_data(result: dict, task_id: str = "") -> ScanReport:
"""扫描 Worker result.json 中的敏感数据。"""
import json
text = json.dumps(result, ensure_ascii=False)
findings: list[ScanFinding] = []
whitelisted = 0
for rule, pattern in SECRET_PATTERNS:
for match in re.finditer(pattern, text, re.IGNORECASE):
matched_text = match.group(0)
if any(re.search(ap, matched_text) for ap in ALLOWLIST_PATTERNS):
whitelisted += 1
continue
display = matched_text[:60] + "..." if len(matched_text) > 60 else matched_text
findings.append(ScanFinding(
rule=rule, file="result.json", line=0, match=display,
))
return ScanReport(
task_id=task_id,
findings=findings,
whitelisted=whitelisted,
clean=len(findings) == 0,
)
def scan_file_with_mode(
file_path: Path,
task_id: str = "",
mode: str = ScanMode.BLOCKING,
confirm_callback=None, # 可选:首次发现时调用此回调询问用户
) -> ScanReport:
"""
增强版扫描:
1. 基础扫描(已有逻辑)
2. 文件名白名单过滤
3. 模式判断advisory vs blocking
"""
report = scan_file(file_path, task_id) # 原有逻辑
# 文件名白名单过滤
filtered_findings = []
for f in report.findings:
filename = file_path.name
if any(re.search(p, filename) for p in WHITELIST_FILE_PATTERNS):
report.whitelisted += 1
continue
filtered_findings.append(f)
report.findings = filtered_findings
report.clean = len(filtered_findings) == 0
# 模式处理
if not report.clean and mode == ScanMode.ADVISORY:
# advisory 模式:只记录,不阻止
report.advisory_blocked = False
elif not report.clean and mode == ScanMode.BLOCKING:
# blocking 模式:默认阻止
report.advisory_blocked = True
return report
def confirm_pattern(task_id: str, pattern: str, user: str = "unknown") -> None:
"""用户确认某模式为安全后,记录下来"""
from air_runtime.utils import now_iso
fingerprint = f"{task_id}:{pattern}"
USER_CONFIRMATIONS[fingerprint] = {
"pattern": pattern,
"confirmed_at": now_iso(),
"user": user,
}
def is_confirmed(task_id: str, pattern: str) -> bool:
"""检查某模式是否已被用户确认"""
fingerprint = f"{task_id}:{pattern}"
return fingerprint in USER_CONFIRMATIONS

View File

@@ -0,0 +1,220 @@
"""
动态任务依赖图DAG— V2 P1-14 修复。
替代 V1 静态 todo.md 表格,支持 Arc 增量重规划Eng 增量吸收。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TaskNode:
id: str
status: str = "TODO" # TODO | DISPATCHED | DONE | BLOCKED
task: str = ""
files_dirs: str = ""
done_when: str = ""
in_degree: int = 0
out_edges: list[str] = field(default_factory=list)
write_set: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class Edge:
source: str
target: str
kind: str = "dependency" # dependency | conflict | sync
@dataclass
class EdgeChange:
added: list[Edge] = field(default_factory=list)
removed: list[Edge] = field(default_factory=list)
@dataclass
class PlanDelta:
"""Arc 重规划产出的增量差异,替代全量覆盖 todo.md。"""
removed_tasks: list[str] = field(default_factory=list)
added_tasks: list[TaskNode] = field(default_factory=list)
modified_tasks: list[TaskNode] = field(default_factory=list)
edge_changes: EdgeChange = field(default_factory=EdgeChange)
class TaskGraph:
"""动态任务依赖图,支持增量更新和全量替换。"""
def __init__(self):
self.nodes: dict[str, TaskNode] = {}
self.edges: list[Edge] = []
def add_node(self, node: TaskNode) -> None:
self.nodes[node.id] = node
def add_edge(self, edge: Edge) -> None:
self.edges.append(edge)
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
def apply_delta(self, delta: PlanDelta) -> None:
"""增量吸收 Arc 的重规划结果,保留已调度任务不受影响。"""
for task_id in delta.removed_tasks:
self._remove_node(task_id)
for node in delta.added_tasks:
self._add_node(node)
for node in delta.modified_tasks:
self._update_node(node)
for edge in delta.edge_changes.removed:
self._remove_edge(edge)
for edge in delta.edge_changes.added:
self._add_edge(edge)
def apply_full_replace(self, nodes: list[TaskNode], edges: list[Edge]) -> None:
"""全量替换模式Arc 产出完整 DAG保留已完成任务状态。"""
done_status = {tid: n.status for tid, n in self.nodes.items() if n.status == "DONE"}
self.nodes = {n.id: n for n in nodes}
self.edges = list(edges)
for tid, status in done_status.items():
if tid in self.nodes:
self.nodes[tid].status = status
for edge in self.edges:
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
def ready_tasks(self) -> list[str]:
"""返回当前入度为 0 且状态为 TODO 的任务。"""
return [nid for nid, n in self.nodes.items() if n.in_degree == 0 and n.status == "TODO"]
def diff(self, other: TaskGraph) -> PlanDelta:
"""对比自身与 other产出 PlanDeltaadd/remove/modify node + edge changes
self = 新图, other = 旧图before replan
"""
delta = PlanDelta()
old_ids = set(other.nodes.keys())
new_ids = set(self.nodes.keys())
# 移除
delta.removed_tasks = list(old_ids - new_ids)
# 新增
delta.added_tasks = [self.nodes[tid] for tid in (new_ids - old_ids)]
# 修改
for tid in old_ids & new_ids:
old_n = other.nodes[tid]
new_n = self.nodes[tid]
if (old_n.task != new_n.task
or old_n.files_dirs != new_n.files_dirs
or old_n.done_when != new_n.done_when
or old_n.write_set != new_n.write_set):
delta.modified_tasks.append(new_n)
# Edge 差异
old_edges = {(e.source, e.target, e.kind) for e in other.edges}
new_edges = {(e.source, e.target, e.kind) for e in self.edges}
for s, t, k in (new_edges - old_edges):
delta.edge_changes.added.append(Edge(source=s, target=t, kind=k))
for s, t, k in (old_edges - new_edges):
delta.edge_changes.removed.append(Edge(source=s, target=t, kind=k))
return delta
@classmethod
def load(cls, path) -> TaskGraph:
"""从 _export_task_graph_json 写的格式还原 TaskGraph。"""
from pathlib import Path
from air_runtime.io import safe_json_load
p = Path(path)
data = safe_json_load(p)
graph = cls()
if not data or not isinstance(data, dict):
return graph
for nid, nd in data.get("nodes", {}).items():
graph.nodes[nid] = TaskNode(
id=nd.get("id", nid),
status=nd.get("status", "TODO"),
task=nd.get("task", ""),
files_dirs=nd.get("filesDirs", ""),
done_when=nd.get("doneWhen", ""),
in_degree=nd.get("inDegree", 0),
out_edges=list(nd.get("outEdges", [])),
write_set=list(nd.get("writeSet", [])),
)
for ed in data.get("edges", []):
graph.edges.append(Edge(
source=ed["source"], target=ed["target"],
kind=ed.get("kind", "dependency"),
))
return graph
def task_ids_by_status(self, status: str) -> list[str]:
return [nid for nid, n in self.nodes.items() if n.status == status]
def find_cycles(self) -> list[list[str]]:
"""检测依赖环DFS"""
visited: set[str] = set()
rec_stack: set[str] = set()
cycles: list[list[str]] = []
def dfs(node_id: str, path: list[str]) -> None:
visited.add(node_id)
rec_stack.add(node_id)
path.append(node_id)
for target in self.nodes.get(node_id, TaskNode(id=node_id)).out_edges:
if target not in visited:
dfs(target, path.copy())
elif target in rec_stack:
cycle_start = path.index(target)
cycles.append(path[cycle_start:])
rec_stack.discard(node_id)
for nid in self.nodes:
if nid not in visited:
dfs(nid, [])
return cycles
def export_todo_md(self) -> str:
"""导出为人可读的 todo.md 表格,保留 V1 的可见性优势。"""
lines = ["| Task | Status | Files/Dirs | Done When | Validation | ADR |",
"|------|--------|------------|-----------|------------|-----|"]
for nid, node in self.nodes.items():
lines.append(f"| {node.task} | {node.status} | {node.files_dirs} | "
f"{node.done_when} | | |")
return "\n".join(lines) + "\n"
def _remove_node(self, task_id: str) -> None:
if task_id in self.nodes:
del self.nodes[task_id]
self.edges = [e for e in self.edges if e.source != task_id and e.target != task_id]
def _add_node(self, node: TaskNode) -> None:
self.nodes[node.id] = node
def _update_node(self, node: TaskNode) -> None:
if node.id in self.nodes:
existing_status = self.nodes[node.id].status
self.nodes[node.id] = node
if existing_status in ("DISPATCHED", "DONE"):
self.nodes[node.id].status = existing_status
def _remove_edge(self, edge: Edge) -> None:
self.edges = [e for e in self.edges
if not (e.source == edge.source and e.target == edge.target)]
if edge.target in self.nodes:
self.nodes[edge.target].in_degree = max(0, self.nodes[edge.target].in_degree - 1)
def _add_edge(self, edge: Edge) -> None:
self.edges.append(edge)
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)

View File

@@ -0,0 +1,183 @@
"""
AirTst 测试运行器运行时 — V2 新增组件。
统一测试执行接口,支持多框架,产出结构化结果。
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import tst_state_path
@dataclass
class TestCase:
name: str
status: str # passed | failed | skipped
duration: str = ""
message: str = ""
@dataclass
class TestSuite:
name: str
total: int = 0
passed: int = 0
failed: int = 0
skipped: int = 0
cases: list[TestCase] = field(default_factory=list)
@dataclass
class TestRunResult:
framework: str
total: int = 0
passed: int = 0
failed: int = 0
disabled: int = 0
duration: str = ""
suites: list[TestSuite] = field(default_factory=list)
failures: list[dict] = field(default_factory=list)
class TestRunner:
"""统一测试执行器。"""
FRAMEWORKS = {
"pytest": ["python", "-m", "pytest", "--json-report", "-q"],
"googletest": ["ctest", "--output-on-failure"],
"jest": ["npx", "jest", "--json"],
"vitest": ["npx", "vitest", "run", "--reporter=json"],
"go": ["go", "test", "-json", "./..."],
"cargo": ["cargo", "test", "--", "--format=json"],
}
def run(self, task_id: str, project_root: Path, framework: str,
target_path: Path | None = None,
extra_args: list[str] | None = None) -> TestRunResult:
if framework not in self.FRAMEWORKS:
return TestRunResult(framework=framework, failures=[{"error": f"unsupported framework: {framework}"}])
cmd = list(self.FRAMEWORKS[framework])
if target_path:
cmd.append(str(target_path))
if extra_args:
cmd.extend(extra_args)
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=600, cwd=str(project_root))
except subprocess.TimeoutExpired:
return TestRunResult(framework=framework, failures=[{"error": "timeout"}])
run_result = self._parse_result(framework, result.stdout)
self._save_report(task_id, project_root, run_result)
return run_result
def _parse_result(self, framework: str, stdout: str) -> TestRunResult:
if framework == "pytest":
return self._parse_pytest(stdout)
if framework in ("jest", "vitest"):
return self._parse_jest(stdout)
if framework == "googletest":
return self._parse_googletest(stdout)
if framework == "go":
return self._parse_go(stdout)
if framework == "cargo":
return self._parse_cargo(stdout)
return TestRunResult(framework=framework, total=0)
def _parse_pytest(self, stdout: str) -> TestRunResult:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return TestRunResult(framework="pytest", failures=[{"error": "json parse failed"}])
return TestRunResult(
framework="pytest",
total=data.get("summary", {}).get("total", 0),
passed=data.get("summary", {}).get("passed", 0),
failed=data.get("summary", {}).get("failed", 0),
duration=str(data.get("duration", "")),
)
def _parse_jest(self, stdout: str) -> TestRunResult:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return TestRunResult(framework="jest", failures=[{"error": "json parse failed"}])
return TestRunResult(
framework="jest",
total=data.get("numTotalTests", 0),
passed=data.get("numPassedTests", 0),
failed=data.get("numFailedTests", 0),
)
def _parse_googletest(self, stdout: str) -> TestRunResult:
"""解析 ctest 输出。ctest 不输出 JSON从文本提取统计。"""
import re
total = passed = failed = disabled = 0
for line in stdout.splitlines():
m = re.match(r"(\d+)% tests passed, (\d+) tests failed out of (\d+)", line)
if m:
failed = int(m.group(2))
total = int(m.group(3))
passed = total - failed
# GoogleTest 也支持 --gtest_output=json
try:
data = json.loads(stdout)
if isinstance(data, dict):
total = sum(s.get("tests", 0) for s in data.get("testsuites", []))
failed = sum(s.get("failures", 0) for s in data.get("testsuites", []))
passed = total - failed
disabled = sum(s.get("disabled", 0) for s in data.get("testsuites", []))
except json.JSONDecodeError:
pass
return TestRunResult(
framework="googletest", total=total, passed=passed, failed=failed, disabled=disabled,
)
def _parse_go(self, stdout: str) -> TestRunResult:
"""解析 go test -json 输出JSONL 格式,每行一个事件)。"""
total = passed = failed = 0
for line in stdout.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
action = ev.get("Action", "")
if action == "pass":
passed += 1
total += 1
elif action == "fail":
failed += 1
total += 1
elif action == "skip":
total += 1
return TestRunResult(framework="go", total=total, passed=passed, failed=failed)
def _parse_cargo(self, stdout: str) -> TestRunResult:
"""解析 cargo test --format=json 输出JSONL 格式)。"""
total = passed = failed = 0
for line in stdout.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "test":
total += 1
if ev.get("event") == "ok":
passed += 1
elif ev.get("event") == "failed":
failed += 1
return TestRunResult(framework="cargo", total=total, passed=passed, failed=failed)
def _save_report(self, task_id: str, project_root: Path, result: TestRunResult) -> None:
report_dir = tst_state_path(project_root).parent / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
from air_runtime.utils import session_stamp
report_path = report_dir / f"{task_id}-{session_stamp()}.json"
atomic_json_write(report_path, result.__dict__)

View File

@@ -0,0 +1,96 @@
"""
TODO 解析器 — V2 修复 P1-8列索引从表头推导不再硬编码 cells[1]/cells[6]/cells[7]。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass
class TodoTask:
task_id: str
task: str
files_dirs: str = ""
status: str = "TODO"
done_when: str = ""
validations: str = ""
adr: str = ""
def parse_tasks(todo_path: Path) -> list[TodoTask]:
"""从 todo.md 解析任务列表,动态检测列索引。"""
if not todo_path.exists():
return []
content = todo_path.read_text(encoding="utf-8")
lines = [l.strip() for l in content.splitlines() if l.strip()]
# 找到 Markdown 表格头
header_idx = -1
for i, line in enumerate(lines):
if line.startswith("|") and "Task" in line and "Status" in line:
header_idx = i
break
if header_idx < 0:
return []
# 解析列名
header_line = lines[header_idx]
header_cols = [c.strip() for c in header_line.split("|") if c.strip()]
# 建立列名 → 索引映射
col_map = {}
for idx, col_name in enumerate(header_cols):
col_name_lower = col_name.lower()
if "task" in col_name_lower:
col_map["task"] = idx
elif "status" in col_name_lower:
col_map["status"] = idx
elif "files" in col_name_lower or "dir" in col_name_lower:
col_map["files_dirs"] = idx
elif "done" in col_name_lower or "when" in col_name_lower:
col_map["done_when"] = idx
elif "valid" in col_name_lower:
col_map["validations"] = idx
elif "adr" in col_name_lower:
col_map["adr"] = idx
# 跳过表头和分隔符
tasks = []
for line in lines[header_idx + 2:]:
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.split("|") if len(c.strip()) > 0]
if not cells:
continue
task_cell = cells[col_map.get("task", 0)] if col_map.get("task", 0) < len(cells) else ""
# 提取 [T-xxx] 格式的任务 ID
tid_match = re.match(r"\[([A-Za-z0-9_\-\.]+)\]", task_cell)
task_id = tid_match.group(1) if tid_match else task_cell
task = task_cell
status = cells[col_map.get("status", 1)] if col_map.get("status", 1) < len(cells) else "TODO"
files_dirs = cells[col_map.get("files_dirs", 2)] if col_map.get("files_dirs", 2) < len(cells) else ""
done_when = cells[col_map.get("done_when", 3)] if col_map.get("done_when", 3) < len(cells) else ""
validations = cells[col_map.get("validations", 4)] if col_map.get("validations", 4) < len(cells) else ""
adr = cells[col_map.get("adr", 5)] if col_map.get("adr", 5) < len(cells) else ""
# 清理标记
task_id = re.sub(r"^\[|\]$", "", task_id).strip()
if task_id and task_id != "---":
tasks.append(TodoTask(
task_id=task_id,
task=task,
files_dirs=files_dirs,
status=status.upper() if status else "TODO",
done_when=done_when,
validations=validations,
adr=adr,
))
return tasks

65
lib/air_runtime/utils.py Normal file
View File

@@ -0,0 +1,65 @@
"""
公共工具函数 — 消除 V1 中 _ordered_unique、_session_stamp、policy normalization 等
在各模块中 3~5 份重复定义的代码。
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any
def ordered_unique(items: list) -> list:
"""保序去重。支持字符串列表和带 id 字段的字典列表。"""
seen: set[str] = set()
result = []
for item in items:
key = item if isinstance(item, str) else item.get("id", str(item))
if key not in seen:
seen.add(key)
result.append(item)
return result
def session_stamp() -> str:
"""统一的文件系统安全时间戳,所有模块共用。"""
return datetime.now(timezone.utc).isoformat().replace(":", "-").replace(".", "-").replace("+", "-")
def now_iso() -> str:
"""ISO 格式 UTC 时间戳,用于 JSON state 文件。"""
return datetime.now(timezone.utc).isoformat()
def normalize_policy(defaults: dict[str, Any], overrides: dict[str, Any] | None) -> dict[str, Any]:
"""通用的策略合并overrides 覆盖 defaults类型自动转换。"""
merged = {**defaults}
if overrides:
for k, v in overrides.items():
if k in merged:
expected_type = type(defaults[k])
try:
merged[k] = expected_type(v) if not isinstance(v, expected_type) else v
except (ValueError, TypeError):
merged[k] = v
return merged
def sanitize_task_id(task_id: str) -> str:
"""防止路径注入:仅允许字母数字、下划线、连字符、点号。"""
if not re.fullmatch(r"[A-Za-z0-9_\-\.]+", task_id):
raise ValueError(f"invalid task_id: {task_id!r}")
return task_id
def sanitize_marker(marker: str) -> str:
"""防止 HTML 注释注入。"""
if "-->" in marker or "<!--" in marker:
raise ValueError(f"marker contains comment delimiters: {marker!r}")
return marker
def truncate_history(data: list, max_items: int = 100) -> list:
"""截断历史列表防止无界增长P2-2"""
return data[-max_items:] if len(data) > max_items else data

157
lib/air_runtime/worktree.py Normal file
View File

@@ -0,0 +1,157 @@
"""
Worktree 隔离并行模块 — V2 P1-15 修复。
区域级冲突检测 + git worktree 隔离,允许同文件不同区域任务安全并行。
"""
from __future__ import annotations
import enum
import subprocess
from dataclasses import dataclass
from pathlib import Path
class ConflictLevel(enum.Enum):
NONE = "none" # 无文件重叠,直接并行
SOFT = "soft" # 同文件不同区域worktree 隔离并行
HARD = "hard" # 同文件同区域,必须串行
@dataclass
class Conflict:
task_a: str
task_b: str
level: ConflictLevel # NONE / SOFT / HARD
reason: str = ""
def to_dict(self) -> dict:
return {
"taskA": self.task_a,
"taskB": self.task_b,
"level": self.level.value,
"reason": self.reason,
}
@dataclass
class MergeResult:
task_id: str
successful: bool
conflicts: list[str]
class RegionConflictDetector:
"""区域级写集冲突检测。
区域定义(按优先级):
1. todo.md 中的区域标注 [region: xxx]
2. 函数/类边界AST 分析或标记注释)
3. 行号区间(从 diff 或任务元数据获取)
"""
def detect(self, task_a_write_set: list[str], task_b_write_set: list[str],
regions_a: dict[str, list[tuple[int, int]]] | None = None,
regions_b: dict[str, list[tuple[int, int]]] | None = None) -> ConflictLevel:
file_overlap = set(task_a_write_set) & set(task_b_write_set)
if not file_overlap:
return ConflictLevel.NONE
if regions_a is None or regions_b is None:
return ConflictLevel.SOFT
for fpath in file_overlap:
ra = regions_a.get(fpath, [(0, 999999)])
rb = regions_b.get(fpath, [(0, 999999)])
for a_start, a_end in ra:
for b_start, b_end in rb:
if a_start < b_end and b_start < a_end:
return ConflictLevel.HARD
return ConflictLevel.SOFT
def detect_batch(self, task_write_sets: dict[str, list[str]],
task_regions: dict[str, dict[str, list[tuple[int, int]]]] | None = None,
) -> list[Conflict]:
"""批量检测:接收 {task_id: [file, ...]} 映射,返回所有冲突对。
逐对调用 detect(),仅返回 level != NONE 的冲突。
"""
conflicts: list[Conflict] = []
task_ids = list(task_write_sets.keys())
for i in range(len(task_ids)):
for j in range(i + 1, len(task_ids)):
a, b = task_ids[i], task_ids[j]
ra = task_regions.get(a) if task_regions else None
rb = task_regions.get(b) if task_regions else None
level = self.detect(task_write_sets[a], task_write_sets[b], ra, rb)
if level != ConflictLevel.NONE:
overlap = sorted(set(task_write_sets[a]) & set(task_write_sets[b]))
reason = f"file overlap: {', '.join(overlap)}" if overlap else ""
if level == ConflictLevel.HARD:
reason = f"region overlap: {', '.join(overlap)}" if overlap else "same region"
conflicts.append(Conflict(task_a=a, task_b=b, level=level, reason=reason))
return conflicts
def extract_regions_from_task(self, file_path: Path, region_markers: list[tuple[int, int]]) -> dict[str, list[tuple[int, int]]]:
"""从任务的区域标注提取行号区间。"""
return {str(file_path): region_markers}
class WorktreeIsolation:
"""为 SOFT 冲突任务创建 git worktree 隔离,完成后合并回主分支。"""
def __init__(self, repo_root: Path):
self._repo_root = repo_root
def create_worktree(self, task_id: str, base_ref: str = "HEAD") -> Path:
branch = f"air-{task_id}"
wt_path = self._repo_root.parent / f"{self._repo_root.name}-air-{task_id}"
subprocess.run(
["git", "-C", str(self._repo_root), "worktree", "add", "-b", branch, str(wt_path), base_ref],
check=True, capture_output=True, text=True,
)
return wt_path
def merge_back(self, task_id: str, wt_path: Path) -> MergeResult:
branch = f"air-{task_id}"
try:
subprocess.run(
["git", "-C", str(self._repo_root), "merge", "--no-ff", branch],
check=True, capture_output=True, text=True,
)
return MergeResult(task_id=task_id, successful=True, conflicts=[])
except subprocess.CalledProcessError as exc:
conflicts = self._parse_conflicts(exc.stderr)
self._abort_merge()
return MergeResult(task_id=task_id, successful=False, conflicts=conflicts)
def cleanup(self, task_id: str, wt_path: Path) -> None:
branch = f"air-{task_id}"
try:
subprocess.run(
["git", "-C", str(self._repo_root), "worktree", "remove", str(wt_path), "--force"],
check=True, capture_output=True, text=True,
)
subprocess.run(
["git", "-C", str(self._repo_root), "branch", "-D", branch],
check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError:
pass
def _abort_merge(self) -> None:
try:
subprocess.run(
["git", "-C", str(self._repo_root), "merge", "--abort"],
check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError:
pass
@staticmethod
def _parse_conflicts(stderr: str) -> list[str]:
conflicts: list[str] = []
for line in stderr.splitlines():
if "CONFLICT" in line:
conflicts.append(line.strip())
return conflicts

View File

@@ -0,0 +1,161 @@
"""AirXDB screenshot capture backends with automatic fallback chain.
Three capture methods:
- KmsGrabCapture: DRM/KMS native screenshot via ffmpeg (no sudo)
- XvfbCapture: Xvfb virtual framebuffer screenshot
- FallbackCapture: text placeholder when no GUI capture is available
CaptureManager tries them in order (kms -> xvfb -> fallback) unless
a specific method is requested.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CaptureResult:
success: bool
output_path: Path | None
method: str # kms, xvfb, fallback
error: str | None = None
class KmsGrabCapture:
"""KMS/DRM screenshot via ffmpeg -f kmsgrab.
No sudo is ever used. The caller must have read access to
/dev/dri/cardX for this to work.
"""
def capture(self, output_path: Path) -> CaptureResult:
# 1. Detect whether the current user can access a DRI device
dri_dev = Path("/dev/dri/card0")
if dri_dev.exists():
try:
# Test read permission (no sudo)
open(dri_dev).close()
except PermissionError:
return CaptureResult(False, None, "kms", "no /dev/dri permission")
else:
return CaptureResult(False, None, "kms", "no GPU")
# 2. Use ffmpeg directly (no sudo)
try:
result = subprocess.run(
[
"ffmpeg", "-y", "-f", "kmsgrab", "-i", "-",
"-frames:v", "1",
"-vf", "hwdownload,format=bgr0",
"-f", "image2", str(output_path),
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0 and output_path.exists():
return CaptureResult(True, output_path, "kms", None)
return CaptureResult(False, None, "kms", result.stderr)
except FileNotFoundError:
return CaptureResult(False, None, "kms", "ffmpeg not found")
except subprocess.TimeoutExpired:
return CaptureResult(False, None, "kms", "ffmpeg timed out")
except Exception as e:
return CaptureResult(False, None, "kms", str(e))
class XvfbCapture:
"""Xvfb virtual display screenshot via xvfb-run + scrot."""
def capture(
self,
output_path: Path,
width: int = 1920,
height: int = 1080,
) -> CaptureResult:
# Check xvfb-run availability
if not shutil.which("xvfb-run"):
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
# Use scrot as the screenshot tool inside Xvfb
try:
result = subprocess.run(
[
"xvfb-run",
"--auto-servernum",
"--server-args",
f"-screen 0 {width}x{height}x24",
"--",
"scrot",
str(output_path),
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0 and output_path.exists():
return CaptureResult(True, output_path, "xvfb", None)
return CaptureResult(False, None, "xvfb", result.stderr)
except FileNotFoundError:
return CaptureResult(False, None, "xvfb", "xvfb-run not found")
except subprocess.TimeoutExpired:
return CaptureResult(False, None, "xvfb", "xvfb-run timed out")
except Exception as e:
return CaptureResult(False, None, "xvfb", str(e))
class FallbackCapture:
"""Fallback: write a text placeholder when no GUI capture is available."""
def capture(
self,
output_path: Path,
description: str = "",
) -> CaptureResult:
output_path.write_text(
f"[GUI capture not available]\n{description}\n"
)
return CaptureResult(True, output_path, "fallback", None)
class CaptureManager:
"""Auto-select the best capture method with fallback chain.
Order: kms -> xvfb -> fallback.
Use ``prefer`` to force a specific method or "auto" for the chain.
"""
def __init__(self) -> None:
self.kms = KmsGrabCapture()
self.xvfb = XvfbCapture()
self.fallback = FallbackCapture()
def capture(
self,
output_path: Path,
prefer: str = "auto",
) -> CaptureResult:
"""
prefer: "kms" | "xvfb" | "fallback" | "auto"
auto mode tries kms -> xvfb -> fallback in order.
"""
if prefer in ("kms", "auto"):
result = self.kms.capture(output_path)
if result.success:
return result
if prefer == "kms":
return result # forced kms failed, return failure
if prefer in ("xvfb", "auto"):
result = self.xvfb.capture(output_path)
if result.success:
return result
if prefer == "xvfb":
return result # forced xvfb failed, return failure
# fallback
return self.fallback.capture(output_path, "all capture methods failed")