feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由

P1-19.1: Arc 边界测试强制
- TaskNode 新增 test_required 字段
- _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务
- Done When 验证必须包含"测试通过"

P1-19.2: AirRvr 高风险审计
- 新增 HighRiskAudit, HighRiskFinding 数据类
- ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict
- 序列化/反序列化支持

P1-19.3: block-release 集成
- dispatch_worker_group() 派发前扫描最新审查报告
- deliveryVerdict=block-release 时阻止所有后续派发
- 记录 eng.blocked 事件

P1-20: frontend-design Skill 集成
- is_ui_task() UI 任务检测
- ensure_frontend_design_skill() 自动安装 Skill
- route_ui_task() UI 任务路由决策
- enter_worker() 集成 UI 检测,skill 不可用时阻止执行
- commands/do.md 更新 UI 处理说明
- SKILL.md 新增 INV-12/INV-13/INV-14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirLongDian
2026-06-10 17:06:48 +08:00
commit e73a4da354
54 changed files with 6054 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"

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,339 @@
#!/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, "testRequired": n.test_required}
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) -> tuple[TaskGraph, list[str]]:
"""从 todo.md 构建初始 DAG返回图和未满足 Done When 条件的任务列表。"""
from air_runtime.todo_parser import parse_tasks
tasks = parse_tasks(todo_path)
graph = TaskGraph()
violations = [] # P1-19.1: 记录 Done When 不含"测试通过"的任务
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)
# P1-19.1: Done When 必须包含"测试通过"
if t.done_when and "测试通过" not in t.done_when:
violations.append(t.task_id)
# P1-19.1: 注入边界测试任务
_inject_boundary_tests(graph, tasks)
return graph, violations
def _inject_boundary_tests(graph: TaskGraph, tasks: list) -> None:
"""P1-19.1: 为每个模块边界注入测试任务。
规则:
- 每个模块的公共接口必须有对应的接口测试任务
- 每个模块的核心逻辑必须有对应的单元测试任务
- 测试任务标记 test_required=True
- Done When 必须包含"测试通过"
"""
# 从 tasks 提取模块信息(通过 files_dirs 推断模块)
module_files: dict[str, set[str]] = {}
for t in tasks:
if t.files_dirs:
for fd in t.files_dirs.split(","):
fd = fd.strip()
if fd:
# 取第一级目录作为模块名
parts = fd.split("/")
if len(parts) > 1:
module = parts[0]
else:
module = fd.split(".")[0] if "." in fd else fd
module_files.setdefault(module, set()).add(fd)
# 为每个模块创建测试任务
test_counter = 0
for module, files in module_files.items():
if not files:
continue
# 接口测试任务(模块边界)
test_counter += 1
interface_test_id = f"T-TEST-{test_counter:03d}"
interface_test_node = TaskNode(
id=interface_test_id,
status="TODO",
task=f"[边界测试] {module} 模块接口测试",
files_dirs=",".join(sorted(files)),
done_when="测试通过",
test_required=True,
)
graph.add_node(interface_test_node)
# 单元测试任务(核心逻辑)
test_counter += 1
unit_test_id = f"T-TEST-{test_counter:03d}"
unit_test_node = TaskNode(
id=unit_test_id,
status="TODO",
task=f"[单元测试] {module} 模块核心逻辑测试",
files_dirs=",".join(sorted(files)),
done_when="测试通过",
test_required=True,
)
graph.add_node(unit_test_node)
# 添加依赖边:实现任务 → 测试任务
for tid, node in graph.nodes.items():
if tid.startswith("T-TEST-"):
continue
# 检查是否属于同一模块
node_files = set(node.files_dirs.split(",")) if node.files_dirs else set()
if node_files & files: # 有交集,说明是同一模块的任务
graph.add_edge(Edge(source=tid, target=interface_test_id, kind="dependency"))
graph.add_edge(Edge(source=tid, target=unit_test_id, kind="dependency"))
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, done_when_violations = _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,
"doneWhenViolations": done_when_violations, # P1-19.1: Done When 不含"测试通过"的任务
"boundaryTestTasks": [n.id for n in graph.nodes.values() if n.test_required],
}
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),
"done_when_violations": done_when_violations,
"boundary_test_task_count": len([n for n in graph.nodes.values() if n.test_required])}
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,255 @@
"""
AirDo mode — V2 任务执行器。
V2 改进:强制 AirDbg 路由L1 代码级task_id 注入防护UI 任务 frontend-design Skill 路由P1-20
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from air_runtime.io import atomic_json_write, safe_json_load
from air_runtime.paths import airplan_root, event_log_path
from air_runtime.events import EventLog, TASK_COMPLETED, TASK_BLOCKED
from air_runtime.contracts import WorkerResult, now_iso
from air_runtime.utils import sanitize_task_id, session_stamp
# P1-20: UI 任务检测关键词
UI_TASK_INDICATORS = (
"gui", "ui", "render", "layout", "dialog", "osd",
"overlay", "visual", "screenshot", "display",
"widget", "pane", "toolbar", "settings_dialog",
"canvas", "button", "window", "popup", "menu",
"drm", "kms", "opengl", "vulkan", "frontend",
"react", "vue", "angular", "web", "css", "html",
)
def is_ui_task(task_text: str) -> bool:
"""P1-20: 检测任务是否涉及 UI/前端界面设计。"""
text = task_text.lower()
return any(kw in text for kw in UI_TASK_INDICATORS)
def ensure_frontend_design_skill() -> bool:
"""P1-20: 检测 frontend-design Skill 是否存在,不存在则尝试自动安装。"""
# 检查 skill 是否已安装(检查 ~/.claude/skills/frontend-design 或类似路径)
import os
home = Path.home()
skill_path = home / ".claude" / "skills" / "frontend-design"
if skill_path.exists():
return True
# 尝试自动安装
import logging
logging.info("frontend-design skill not found, attempting auto-install...")
try:
result = subprocess.run(
["claude", "plugin", "install", "frontend-design"],
capture_output=True, text=True, timeout=60,
)
if result.returncode == 0:
logging.info("frontend-design skill installed successfully")
return True
logging.warning("frontend-design skill install failed: %s", result.stderr)
except Exception as e:
logging.warning("frontend-design skill install error: %s", e)
return False
def route_ui_task(task_text: str, task_id: str) -> dict:
"""P1-20: UI 任务路由决策。检测 UI 任务并确保 frontend-design Skill 可用。"""
if not is_ui_task(task_text):
return {"target": "execute", "skill": None, "is_ui_task": False}
# 是 UI 任务,检查 skill 可用性
if ensure_frontend_design_skill():
return {"target": "execute", "skill": "frontend-design", "is_ui_task": True}
# Skill 不可用,阻止任务
return {
"target": "blocked",
"reason": "UI task requires frontend-design skill but installation failed",
"skill": "frontend-design",
"is_ui_task": True,
}
def _paths(project_root: Path, task_id: str) -> dict[str, Path]:
root = airplan_root(project_root) / "state" / "airdo"
task_dir = root / "tasks" / task_id
return {
"root": root,
"state": root / "state.json",
"task_dir": task_dir,
"brief": task_dir / "brief.md",
"handoff": task_dir / "subagent-handoff.md",
"result": task_dir / "result.json",
"worker_state": task_dir / "worker-state.json",
}
def _ensure_dirs(paths: dict[str, Path]) -> None:
paths["task_dir"].mkdir(parents=True, exist_ok=True)
def enter_worker(project_root: Path, task_id: str, task_text: str = "") -> dict:
"""P1-20: 新增 task_text 参数用于 UI 任务检测。"""
tid = sanitize_task_id(task_id)
paths = _paths(project_root, tid)
_ensure_dirs(paths)
# P1-20: UI 任务检测和路由
ui_routing = {"target": "execute", "skill": None, "is_ui_task": False}
if task_text:
ui_routing = route_ui_task(task_text, tid)
if ui_routing.get("target") == "blocked":
# UI 任务但 skill 不可用,阻止执行
worker_state = {
"taskId": tid, "status": "blocked",
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
"blockReason": ui_routing.get("reason", "frontend-design skill unavailable"),
"uiRouting": ui_routing,
}
atomic_json_write(paths["worker_state"], worker_state)
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
"updatedAt": now_iso(), "blocked": True})
log = EventLog(event_log_path(project_root))
log.emit("task.blocked", {"taskId": tid, "reason": ui_routing.get("reason")})
return {
"taskId": tid, "status": "blocked",
"blockReason": ui_routing.get("reason"),
"uiRouting": ui_routing,
}
worker_state = {
"taskId": tid, "status": "implementing",
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
"uiRouting": ui_routing,
}
atomic_json_write(paths["worker_state"], worker_state)
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
"updatedAt": now_iso()})
log = EventLog(event_log_path(project_root))
log.emit("task.entered", {"taskId": tid, "uiRouting": ui_routing})
return {
"taskId": tid, "briefPath": str(paths["brief"]),
"handoffPath": str(paths["handoff"]),
"resultPath": str(paths["result"]),
"workerStatePath": str(paths["worker_state"]),
"uiRouting": ui_routing,
}
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
"""V2 核心改进:强制 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,606 @@
"""
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)
# P1-19.3: 检查是否有 block-release verdict阻止所有后续派发
from air_runtime.review_runtime import ReviewRuntime
rvr = ReviewRuntime(project_root)
# 扫描最新的审查报告,检查是否有 block-release
rvr_state = rvr._state_dir / "reports"
block_release_found = False
latest_verdict = "safe-to-ship"
if rvr_state.exists():
for report_file in sorted(rvr_state.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
report_data = safe_json_load(report_file)
if report_data:
dv = report_data.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship")
if dv == "block-release":
block_release_found = True
latest_verdict = dv
break
elif dv == "needs-fix":
latest_verdict = dv
if block_release_found:
log = EventLog(event_log_path(project_root))
log.emit("eng.blocked", {"reason": "block-release verdict from review", "verdict": latest_verdict})
return {
"blocked": True,
"reason": "block-release verdict from AirRvr review - all dispatches halted",
"deliveryVerdict": latest_verdict,
"waveId": "",
"taskIds": [],
}
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,195 @@
"""
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 HighRiskFinding:
"""P1-19.2: 高风险审计发现项。"""
file: str
line: int
severity: str # critical | high | medium | low
issue: str
@dataclass
class HighRiskAudit:
"""P1-19.2: 高风险审计报告结构。"""
lifecycle: list[HighRiskFinding] = field(default_factory=list)
nullPointer: list[HighRiskFinding] = field(default_factory=list)
danglingPointer: list[HighRiskFinding] = field(default_factory=list)
exceptionSafety: list[HighRiskFinding] = field(default_factory=list)
concurrency: list[HighRiskFinding] = field(default_factory=list)
overallRisk: str = "low" # critical | high | medium | low
deliveryVerdict: str = "safe-to-ship" # safe-to-ship | needs-fix | block-release
@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,
})
high_risk_audit: HighRiskAudit = field(default_factory=HighRiskAudit) # P1-19.2
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": [], "deliveryVerdict": "safe-to-ship"}
report = safe_json_load(report_path)
if not report or not isinstance(report, dict):
return {"verdict": "pass", "reportPath": str(report_path), "residual": [], "deliveryVerdict": "safe-to-ship"}
return {
"verdict": report.get("verdict", "pass"),
"residual": report.get("residual", []),
"reportPath": str(report_path),
"summary": report.get("summary", ""),
"deliveryVerdict": report.get("highRiskAudit", {}).get("deliveryVerdict", "safe-to-ship"), # P1-19.2
}
@staticmethod
def _report_to_dict(report: ReviewReport) -> dict:
# P1-19.2: highRiskAudit 序列化
hra = report.high_risk_audit
high_risk_audit_dict = {
"lifecycle": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.lifecycle],
"nullPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.nullPointer],
"danglingPointer": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.danglingPointer],
"exceptionSafety": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.exceptionSafety],
"concurrency": [{"file": f.file, "line": f.line, "severity": f.severity, "issue": f.issue} for f in hra.concurrency],
"overallRisk": hra.overallRisk,
"deliveryVerdict": hra.deliveryVerdict,
}
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,
"highRiskAudit": high_risk_audit_dict,
"recommendations": report.recommendations,
}
@staticmethod
def _dict_to_report(data: dict) -> ReviewReport:
# P1-19.2: highRiskAudit 反序列化
hra_data = data.get("highRiskAudit", {})
high_risk_audit = HighRiskAudit(
lifecycle=[HighRiskFinding(**f) for f in hra_data.get("lifecycle", [])],
nullPointer=[HighRiskFinding(**f) for f in hra_data.get("nullPointer", [])],
danglingPointer=[HighRiskFinding(**f) for f in hra_data.get("danglingPointer", [])],
exceptionSafety=[HighRiskFinding(**f) for f in hra_data.get("exceptionSafety", [])],
concurrency=[HighRiskFinding(**f) for f in hra_data.get("concurrency", [])],
overallRisk=hra_data.get("overallRisk", "low"),
deliveryVerdict=hra_data.get("deliveryVerdict", "safe-to-ship"),
)
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", {}),
high_risk_audit=high_risk_audit,
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,222 @@
"""
动态任务依赖图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)
test_required: bool = False # P1-19.1: 边界测试强制标记
@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", [])),
test_required=nd.get("testRequired", False),
)
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")