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,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)}")