feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化
P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由 - GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg - 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过 P1-GAP17: 事件 emit 规范化 - 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等) - 全部 emit 调用替换字符串字面量为常量,零残留 - 30 个事件类型常量全部定义且唯一 P1-GAP18: 事件日志原子轮转 - emit 计数器每 128 次检查轮转,避免每次 emit 读文件 - 清除未使用的 _emit_with_completion/_pending_merge_complete - 原子轮转: tempfile+os.replace 保证不损坏 eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr) commands/do.md: 更新为全专家插件路由文档 全量测试: 69 通过, 0 失败 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
lib/air_runtime/modes/__init__.py
Executable file
11
lib/air_runtime/modes/__init__.py
Executable 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",
|
||||
]
|
||||
382
lib/air_runtime/modes/arc_mode.py
Executable file
382
lib/air_runtime/modes/arc_mode.py
Executable file
@@ -0,0 +1,382 @@
|
||||
#!/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, ARC_REPLANNED
|
||||
from air_runtime.paths import event_log_path
|
||||
from air_runtime.utils import ordered_unique
|
||||
|
||||
|
||||
# INV-16: 弱模型安全 — 危险词列表
|
||||
DANGEROUS_TASK_WORDS = [
|
||||
"清理", "清除", "删除所有", "重构整个", "重写全部",
|
||||
"clear", "delete all", "remove all", "rewrite entire", "refactor whole",
|
||||
]
|
||||
DIR_LEVEL_FILE_SCOPE_PATTERNS = [
|
||||
"src/", "lib/", "include/", "tests/", "modules/",
|
||||
]
|
||||
|
||||
def _audit_task_safety(task_id: str, task_text: str, files_dirs: str, done_when: str) -> list[str]:
|
||||
"""INV-16: 扫描单个任务的危险词和宽泛文件范围,返回警告列表。"""
|
||||
warnings = []
|
||||
text_lower = task_text.lower()
|
||||
for word in DANGEROUS_TASK_WORDS:
|
||||
if word.lower() in text_lower:
|
||||
warnings.append(f"[{task_id}] 含危险词 '{word}' — 建议改为精确描述(如'修改 CMakeLists.txt 去掉 sipclient 依赖'而非'清理{word}')")
|
||||
for pattern in DIR_LEVEL_FILE_SCOPE_PATTERNS:
|
||||
if pattern in files_dirs and not any(f.endswith(ext) for ext in [".cpp", ".hpp", ".h", ".py", ".ts", ".md", ".json", ".txt", ".cmake"] for f in files_dirs.split(",")):
|
||||
if not done_when or "不" not in done_when:
|
||||
warnings.append(f"[{task_id}] 文件范围含目录级 '{pattern}' 但 done_when 无否定约束 — Worker 可能误解文件范围")
|
||||
break
|
||||
return warnings
|
||||
|
||||
|
||||
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 = {
|
||||
"dispatchFrozen": graph.dispatch_frozen, # P1-21
|
||||
"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,
|
||||
"adrRefs": n.adr_refs} # P1-21
|
||||
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:
|
||||
# P1-21: 从 todo.md ADR 列提取 adr_refs
|
||||
adr_refs = []
|
||||
if hasattr(t, "adr") and t.adr:
|
||||
adr_refs = [a.strip() for a in t.adr.split(",") if a.strip()]
|
||||
node = TaskNode(
|
||||
id=t.task_id, status=t.status, task=t.task,
|
||||
files_dirs=t.files_dirs, done_when=t.done_when,
|
||||
adr_refs=adr_refs,
|
||||
)
|
||||
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")))
|
||||
|
||||
# INV-16: 弱模型安全审计 — 扫描所有 TODO 任务的危险词和宽泛文件范围
|
||||
safety_warnings = []
|
||||
for nid, node in graph.nodes.items():
|
||||
if node.status == "TODO":
|
||||
safety_warnings.extend(
|
||||
_audit_task_safety(nid, node.task, node.files_dirs, node.done_when)
|
||||
)
|
||||
|
||||
_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],
|
||||
"safetyWarnings": safety_warnings, # INV-16: 弱模型安全警告
|
||||
}
|
||||
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")
|
||||
|
||||
# 保留 arcPhase 字段,避免 phase 被 reset 为 discussing
|
||||
existing_state = safe_json_load(paths["state"]) or {}
|
||||
state_payload = {**existing_state, "enabled": True, "updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root)}
|
||||
atomic_json_write(paths["state"], state_payload)
|
||||
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)}")
|
||||
232
lib/air_runtime/modes/ctx_mode.py
Executable file
232
lib/air_runtime/modes/ctx_mode.py
Executable 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)}")
|
||||
279
lib/air_runtime/modes/dbg_mode.py
Executable file
279
lib/air_runtime/modes/dbg_mode.py
Executable 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)}")
|
||||
39
lib/air_runtime/modes/dep_mode.py
Executable file
39
lib/air_runtime/modes/dep_mode.py
Executable 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)}")
|
||||
370
lib/air_runtime/modes/do_mode.py
Executable file
370
lib/air_runtime/modes/do_mode.py
Executable file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
AirDo mode — V2 任务执行器。
|
||||
V2 改进:全专家插件强制路由(L1 代码级),task_id 注入防护,UI 任务 frontend-design Skill 路由(P1-20)。
|
||||
路由规则:GUI→XDB, network→NDB, C/C++→SDB, blocked/failed→Dbg, done→Rvr
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from air_runtime.io import atomic_json_write, safe_json_load
|
||||
from air_runtime.paths import airplan_root, event_log_path
|
||||
from air_runtime.events import EventLog, TASK_COMPLETED, TASK_BLOCKED, TASK_ENTERED, TASK_FINISHED
|
||||
from air_runtime.contracts import WorkerResult, now_iso
|
||||
from air_runtime.utils import sanitize_task_id, session_stamp
|
||||
|
||||
|
||||
# GUI 任务检测关键词
|
||||
GUI_INDICATORS = {
|
||||
"gui", "ui", "render", "layout", "dialog", "osd",
|
||||
"overlay", "visual", "screenshot", "display",
|
||||
"widget", "pane", "toolbar", "settings_dialog",
|
||||
"界面", "渲染", "布局", "按钮", "对话框", "窗口", "菜单", "控件",
|
||||
}
|
||||
|
||||
# 网络任务检测关键词
|
||||
NETWORK_INDICATORS = {
|
||||
"network", "rtsp", "http", "tcp", "udp", "tls",
|
||||
"dns", "proxy", "socket", "stream", "port",
|
||||
"网络", "抓包", "rtmp", "webrtc", "sip",
|
||||
}
|
||||
|
||||
# C/C++ 文件扩展名
|
||||
CPP_EXTENSIONS = {".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".h", ".h++"}
|
||||
|
||||
|
||||
def _has_gui_indicators(task_text: str, files_dirs: str) -> bool:
|
||||
text = f"{task_text} {files_dirs}".lower()
|
||||
return any(kw in text for kw in GUI_INDICATORS)
|
||||
|
||||
|
||||
def _has_network_indicators(task_text: str, files_dirs: str) -> bool:
|
||||
text = f"{task_text} {files_dirs}".lower()
|
||||
return any(kw in text for kw in NETWORK_INDICATORS)
|
||||
|
||||
|
||||
def _has_cpp_files(files_changed: list[str]) -> bool:
|
||||
return any(
|
||||
any(f.endswith(ext) for ext in CPP_EXTENSIONS)
|
||||
for f in files_changed
|
||||
)
|
||||
|
||||
|
||||
# P1-20: UI 任务检测关键词(支持中英文)
|
||||
UI_TASK_INDICATORS = (
|
||||
# 英文关键词
|
||||
"gui", "ui", "render", "layout", "dialog", "osd",
|
||||
"overlay", "visual", "screenshot", "display",
|
||||
"widget", "pane", "toolbar", "settings_dialog",
|
||||
"canvas", "button", "window", "popup", "menu",
|
||||
"drm", "kms", "opengl", "vulkan", "frontend",
|
||||
"react", "vue", "angular", "web", "css", "html",
|
||||
# 中文关键词
|
||||
"界面", "UI", "界面设计", "前端", "界面开发",
|
||||
"按钮", "对话框", "窗口", "菜单", "控件",
|
||||
"渲染", "布局", "登录界面", "界面组件",
|
||||
)
|
||||
|
||||
|
||||
def is_ui_task(task_text: str) -> bool:
|
||||
"""P1-20: 检测任务是否涉及 UI/前端界面设计。"""
|
||||
text = task_text.lower()
|
||||
return any(kw in text for kw in UI_TASK_INDICATORS)
|
||||
|
||||
|
||||
def ensure_frontend_design_skill() -> bool:
|
||||
"""P1-20: 检测 frontend-design Skill 是否存在,不存在则尝试自动安装。"""
|
||||
# 检查 skill 是否已安装(检查 ~/.claude/skills/frontend-design 或类似路径)
|
||||
import os
|
||||
home = Path.home()
|
||||
skill_path = home / ".claude" / "skills" / "frontend-design"
|
||||
if skill_path.exists():
|
||||
return True
|
||||
|
||||
# 尝试自动安装
|
||||
import logging
|
||||
logging.info("frontend-design skill not found, attempting auto-install...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "plugin", "install", "frontend-design"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logging.info("frontend-design skill installed successfully")
|
||||
return True
|
||||
logging.warning("frontend-design skill install failed: %s", result.stderr)
|
||||
except Exception as e:
|
||||
logging.warning("frontend-design skill install error: %s", e)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def route_ui_task(task_text: str, task_id: str) -> dict:
|
||||
"""P1-20: UI 任务路由决策。检测 UI 任务并确保 frontend-design Skill 可用。"""
|
||||
if not is_ui_task(task_text):
|
||||
return {"target": "execute", "skill": None, "is_ui_task": False}
|
||||
|
||||
# 是 UI 任务,检查 skill 可用性
|
||||
if ensure_frontend_design_skill():
|
||||
return {"target": "execute", "skill": "frontend-design", "is_ui_task": True}
|
||||
|
||||
# Skill 不可用,阻止任务
|
||||
return {
|
||||
"target": "blocked",
|
||||
"reason": "UI task requires frontend-design skill but installation failed",
|
||||
"skill": "frontend-design",
|
||||
"is_ui_task": True,
|
||||
}
|
||||
|
||||
|
||||
def _paths(project_root: Path, task_id: str) -> dict[str, Path]:
|
||||
root = airplan_root(project_root) / "state" / "airdo"
|
||||
task_dir = root / "tasks" / task_id
|
||||
return {
|
||||
"root": root,
|
||||
"state": root / "state.json",
|
||||
"task_dir": task_dir,
|
||||
"brief": task_dir / "brief.md",
|
||||
"handoff": task_dir / "subagent-handoff.md",
|
||||
"result": task_dir / "result.json",
|
||||
"worker_state": task_dir / "worker-state.json",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_dirs(paths: dict[str, Path]) -> None:
|
||||
paths["task_dir"].mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def enter_worker(project_root: Path, task_id: str, task_text: str = "") -> dict:
|
||||
"""P1-20: 新增 task_text 参数用于 UI 任务检测。"""
|
||||
tid = sanitize_task_id(task_id)
|
||||
paths = _paths(project_root, tid)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
# P1-20: UI 任务检测和路由
|
||||
ui_routing = {"target": "execute", "skill": None, "is_ui_task": False}
|
||||
if task_text:
|
||||
ui_routing = route_ui_task(task_text, tid)
|
||||
|
||||
if ui_routing.get("target") == "blocked":
|
||||
# UI 任务但 skill 不可用,阻止执行
|
||||
worker_state = {
|
||||
"taskId": tid, "status": "blocked",
|
||||
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
|
||||
"blockReason": ui_routing.get("reason", "frontend-design skill unavailable"),
|
||||
"uiRouting": ui_routing,
|
||||
}
|
||||
atomic_json_write(paths["worker_state"], worker_state)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
|
||||
"updatedAt": now_iso(), "blocked": True})
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(TASK_BLOCKED, {"taskId": tid, "reason": ui_routing.get("reason")})
|
||||
return {
|
||||
"taskId": tid, "status": "blocked",
|
||||
"blockReason": ui_routing.get("reason"),
|
||||
"uiRouting": ui_routing,
|
||||
}
|
||||
|
||||
worker_state = {
|
||||
"taskId": tid, "status": "implementing",
|
||||
"enteredAt": now_iso(), "resultPath": str(paths["result"]),
|
||||
"uiRouting": ui_routing,
|
||||
}
|
||||
atomic_json_write(paths["worker_state"], worker_state)
|
||||
atomic_json_write(paths["state"], {"enabled": True, "activeTaskId": tid,
|
||||
"updatedAt": now_iso()})
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(TASK_ENTERED, {"taskId": tid, "uiRouting": ui_routing})
|
||||
|
||||
return {
|
||||
"taskId": tid, "briefPath": str(paths["brief"]),
|
||||
"handoffPath": str(paths["handoff"]),
|
||||
"resultPath": str(paths["result"]),
|
||||
"workerStatePath": str(paths["worker_state"]),
|
||||
"uiRouting": ui_routing,
|
||||
}
|
||||
|
||||
|
||||
def finish_worker(project_root: Path, task_id: str, result_path: Path | None = None) -> dict:
|
||||
"""V2 核心改进:全专家插件强制路由。
|
||||
|
||||
路由规则(按优先级):
|
||||
1. blocked/failed → AirDbg(调试定位根因)
|
||||
2. done 无证据 → AirDbg(审查验证)
|
||||
3. GUI 任务 → AirXDB(截图取证)
|
||||
4. 网络任务 → AirNDB(抓包取证)
|
||||
5. C/C++ 任务 → AirSDB(静态分析)
|
||||
6. 所有 done 任务 → AirRvr(需求一致性审查)
|
||||
无强制路由时才允许 merge。
|
||||
"""
|
||||
tid = sanitize_task_id(task_id)
|
||||
paths = _paths(project_root, tid)
|
||||
|
||||
# 加载 result
|
||||
if result_path and result_path.exists():
|
||||
result_data = safe_json_load(result_path)
|
||||
elif paths["result"].exists():
|
||||
result_data = safe_json_load(paths["result"])
|
||||
else:
|
||||
result_data = {"taskId": tid, "status": "blocked", "summary": "no result found"}
|
||||
|
||||
if not isinstance(result_data, dict):
|
||||
result_data = {"taskId": tid, "status": "blocked"}
|
||||
|
||||
result = WorkerResult.from_dict(result_data)
|
||||
status = result.status
|
||||
|
||||
# 从 task-graph.json 获取任务描述用于分类
|
||||
task_text = ""
|
||||
files_dirs = ""
|
||||
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if tg_json.exists():
|
||||
from air_runtime.task_graph import TaskGraph
|
||||
graph = TaskGraph.load(tg_json)
|
||||
node = graph.nodes.get(tid)
|
||||
if node:
|
||||
task_text = node.task
|
||||
files_dirs = node.files_dirs
|
||||
|
||||
decisions = []
|
||||
|
||||
# 1. blocked/failed → 强制 AirDbg(最高优先级)
|
||||
if status in ("blocked", "failed"):
|
||||
decisions.append({
|
||||
"target": "airdbg", "forced": True,
|
||||
"reason": f"status={status} — AirDbg mandatory before return",
|
||||
})
|
||||
|
||||
# 2. done 但无实质验证 → 强制 AirDbg
|
||||
elif status == "done":
|
||||
if not result.validations and not result.files_changed:
|
||||
decisions.append({
|
||||
"target": "airdbg", "forced": True,
|
||||
"reason": "done without evidence — mandatory debug review",
|
||||
})
|
||||
|
||||
# 3. GUI 任务 → 强制 AirXDB 截图
|
||||
if _has_gui_indicators(task_text, files_dirs):
|
||||
xdb_sessions = result_data.get("xdbSessions") or result_data.get("xdb_sessions") or []
|
||||
if not xdb_sessions:
|
||||
decisions.append({
|
||||
"target": "airxdb", "forced": True,
|
||||
"reason": "GUI task requires screenshot evidence",
|
||||
})
|
||||
|
||||
# 4. 网络任务 → 强制 AirNDB 抓包
|
||||
if _has_network_indicators(task_text, files_dirs):
|
||||
ndb_sessions = result_data.get("ndbSessions") or result_data.get("ndb_sessions") or []
|
||||
if not ndb_sessions:
|
||||
decisions.append({
|
||||
"target": "airndb", "forced": True,
|
||||
"reason": "network task requires packet capture evidence",
|
||||
})
|
||||
|
||||
# 5. C/C++ 任务 → 强制 AirSDB 静态分析
|
||||
if _has_cpp_files(result.files_changed):
|
||||
sdb_reports = result_data.get("sdbReports") or result_data.get("sdb_reports") or []
|
||||
if not sdb_reports:
|
||||
decisions.append({
|
||||
"target": "airsdb", "forced": True,
|
||||
"reason": "C/C++ task requires static analysis",
|
||||
})
|
||||
|
||||
# 6. 所有 done 任务 → 强制 AirRvr 审查(已完成则跳过)
|
||||
rvr_reviewed = (
|
||||
result_data.get("rvrReviewed") or
|
||||
result_data.get("rvr_reviewed") or
|
||||
result_data.get("rvrReviews") or
|
||||
result_data.get("rvr_reviews") or
|
||||
[]
|
||||
)
|
||||
if not rvr_reviewed:
|
||||
decisions.append({
|
||||
"target": "airrvr", "forced": True,
|
||||
"reason": "completed task requires requirements review",
|
||||
})
|
||||
|
||||
# 无强制路由时才允许 merge
|
||||
if not decisions:
|
||||
decisions.append({"target": "merge", "forced": False})
|
||||
|
||||
# 持久化
|
||||
finalized = result.to_dict()
|
||||
finalized["routingDecisions"] = decisions
|
||||
finalized["routingDecision"] = decisions[0] # 向后兼容:主路由决策
|
||||
finalized["finalizedAt"] = now_iso()
|
||||
atomic_json_write(paths["result"], finalized)
|
||||
atomic_json_write(paths["worker_state"], {
|
||||
"taskId": tid, "status": "finished",
|
||||
"resultPath": str(paths["result"]),
|
||||
"routingDecisions": decisions,
|
||||
"routingDecision": decisions[0],
|
||||
})
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(TASK_FINISHED, {
|
||||
"taskId": tid, "status": status,
|
||||
"routingTargets": [d["target"] for d in decisions],
|
||||
})
|
||||
|
||||
# emit task.completed / task.blocked based on final status
|
||||
if status == "done":
|
||||
log.emit(TASK_COMPLETED, {"taskId": tid,
|
||||
"routingTargets": [d["target"] for d in decisions]})
|
||||
elif status in ("blocked", "failed"):
|
||||
log.emit(TASK_BLOCKED, {"taskId": tid, "status": status})
|
||||
|
||||
return {
|
||||
"taskId": tid, "status": status,
|
||||
"finalizedResultPath": str(paths["result"]),
|
||||
"workerStatePath": str(paths["worker_state"]),
|
||||
"routingDecisions": decisions,
|
||||
"routingDecision": decisions[0],
|
||||
}
|
||||
|
||||
|
||||
def status_worker(project_root: Path) -> dict:
|
||||
paths = _paths(project_root, "_")
|
||||
state = safe_json_load(paths["state"]) or {}
|
||||
task_ids = []
|
||||
if paths["root"].joinpath("tasks").exists():
|
||||
task_ids = [d.name for d in paths["root"].joinpath("tasks").iterdir() if d.is_dir()]
|
||||
return {
|
||||
"enabled": state.get("enabled", False),
|
||||
"activeTaskId": state.get("activeTaskId", ""),
|
||||
"taskIds": task_ids,
|
||||
}
|
||||
|
||||
|
||||
def main(args) -> None:
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
sub = args.sub or "status"
|
||||
tid = args.task_id
|
||||
|
||||
if sub == "status":
|
||||
s = status_worker(project_root)
|
||||
print("airplan_mode=do")
|
||||
print(f"enabled={s['enabled']}")
|
||||
print(f"active_task_id={s['activeTaskId']}")
|
||||
print(f"known_tasks={','.join(s['taskIds'])}")
|
||||
elif sub == "enter":
|
||||
task_text = getattr(args, "task_text", "") or ""
|
||||
result = enter_worker(project_root, tid, task_text=task_text)
|
||||
print("airplan_mode=do")
|
||||
print(f"task_id={result['taskId']}")
|
||||
print(f"brief_path={result['briefPath']}")
|
||||
print(f"result_path={result['resultPath']}")
|
||||
print(f"worker_state_path={result['workerStatePath']}")
|
||||
elif sub == "finish":
|
||||
rpath = Path(args.result).expanduser().resolve() if args.result else None
|
||||
finalized = finish_worker(project_root, tid, rpath)
|
||||
targets = [d["target"] for d in finalized.get("routingDecisions", [])]
|
||||
print("airplan_mode=do")
|
||||
print(f"task_id={finalized['taskId']}")
|
||||
print(f"status={finalized['status']}")
|
||||
print(f"routing_targets={','.join(targets)}")
|
||||
print(f"routing_forced={any(d.get('forced') for d in finalized.get('routingDecisions', []))}")
|
||||
898
lib/air_runtime/modes/eng_mode.py
Executable file
898
lib/air_runtime/modes/eng_mode.py
Executable file
@@ -0,0 +1,898 @@
|
||||
"""
|
||||
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, ENGINE_ENTERED, ENG_REPLAN_TRIGGERED, ENG_BLOCKED, \
|
||||
WORKER_TIMEOUT, REPAIR_CREATED, REPAIR_RESOLVED, \
|
||||
ADR_CHANGE_DETECTED, ADR_INVALIDATION, ADR_UNFREEZED, WORKTREE_MERGE_CONFLICT
|
||||
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, CascadeReport, PlanDelta
|
||||
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-21: 检查调度冻结(ADR 级联失效期间)
|
||||
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if tg_json.exists():
|
||||
try:
|
||||
graph = TaskGraph.load(tg_json)
|
||||
if graph.dispatch_frozen:
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(ENG_BLOCKED, {"reason": "dispatch frozen — ADR cascade invalidation in progress"})
|
||||
return {
|
||||
"blocked": True,
|
||||
"reason": "dispatch frozen — ADR cascade invalidation in progress",
|
||||
"waveId": "",
|
||||
"taskIds": [],
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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 _detect_adr_changes(project_root: Path, state: dict) -> list:
|
||||
"""P1-21: 检查 ADR 文件变更,返回需要级联失效的变更列表。"""
|
||||
from air_runtime.adr_watcher import ADRWatcher, ADRChange
|
||||
adr_dir = project_root / "AirPlan" / "docs" / "architecture" / "adr"
|
||||
if not adr_dir.exists():
|
||||
return []
|
||||
|
||||
watcher = ADRWatcher(adr_dir)
|
||||
# 从引擎状态恢复已知 hash
|
||||
known = state.get("adrWatcherHashes", {})
|
||||
watcher._known_hashes = known
|
||||
|
||||
# 首次无 snapshot → 先初始化
|
||||
if not known:
|
||||
watcher.snapshot()
|
||||
state["adrWatcherHashes"] = dict(watcher._known_hashes)
|
||||
return []
|
||||
|
||||
changes = watcher.detect_changes()
|
||||
# 持久化更新后的 hash
|
||||
state["adrWatcherHashes"] = dict(watcher._known_hashes)
|
||||
|
||||
# 只返回需要级联失效的变更
|
||||
return [c for c in changes if c.kind in ("superseded", "modified")]
|
||||
|
||||
|
||||
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
|
||||
|
||||
# P1-21: ADR 变更自动检测
|
||||
adr_changes = _detect_adr_changes(project_root, state)
|
||||
if adr_changes:
|
||||
for change in adr_changes:
|
||||
if change.kind in ("superseded", "modified"):
|
||||
interventions.append({
|
||||
"adrId": change.adr_id,
|
||||
"reason": f"adr-{change.kind}",
|
||||
"action": "invalidate-by-adr",
|
||||
})
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(ADR_CHANGE_DETECTED, {
|
||||
"adrId": change.adr_id, "kind": change.kind,
|
||||
})
|
||||
|
||||
# 新增:检查 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)
|
||||
|
||||
# Phase 6.5: 同步 task-graph.json 节点状态(P1-24)
|
||||
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if tg_json.exists():
|
||||
from air_runtime.modes.arc_mode import _export_task_graph_json
|
||||
graph = TaskGraph.load(tg_json)
|
||||
if task_id in graph.nodes:
|
||||
new_status = "DONE" if status == "done" else status.upper()
|
||||
graph.nodes[task_id].status = new_status
|
||||
_export_task_graph_json(graph, tg_json)
|
||||
|
||||
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 task。
|
||||
DAG 中 ready 为空意味着无任务可派发(全部完成或全部被依赖阻塞),不应 fallback 到 todo.md。"""
|
||||
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()
|
||||
return ready[:max_count] # 空列表也是正确答案,不 fallback
|
||||
except Exception:
|
||||
pass
|
||||
# fallback:仅在 task-graph.json 不存在时使用 todo.md
|
||||
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 spawn_workers(project_root: Path, task_ids: list[str]) -> list[dict]:
|
||||
"""T-1.21: 为每个 ready 任务准备 Agent 派发指令。
|
||||
|
||||
使用 Agent 工具(非 Skill 工具)spawn 隔离子 Agent。
|
||||
每个子 Agent 有自己的上下文,不继承 Eng 的完整对话。这正是 INV-2(fork_context=false)。
|
||||
|
||||
返回 Agent 调用参数列表,Eng Agent 遍历列表逐个调用。
|
||||
"""
|
||||
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
graph = TaskGraph.load(tg_json) if tg_json.exists() else TaskGraph()
|
||||
instructions = []
|
||||
for tid in task_ids:
|
||||
node = graph.nodes.get(tid)
|
||||
task_text = node.task if node else ""
|
||||
files = node.files_dirs if node else ""
|
||||
done_when = node.done_when if node else ""
|
||||
|
||||
prompt_parts = [
|
||||
f"你是 AirDo Worker,任务 ID: {tid}。",
|
||||
f"项目路径: {project_root}",
|
||||
"",
|
||||
f"## 任务",
|
||||
f"{task_text}",
|
||||
"",
|
||||
f"## 文件范围",
|
||||
f"{files}" if files else "(无限制)",
|
||||
"",
|
||||
f"## 完成标准",
|
||||
f"{done_when}" if done_when else "编译通过,无回归",
|
||||
"",
|
||||
"## 工作流程",
|
||||
"1. 先运行 `python scripts/airplan.py --mode do --sub enter --task-id {tid} --task-text '{task_text}' --project {project_root}` 初始化 Worker 状态",
|
||||
"2. 读取项目文件,理解现有代码结构",
|
||||
"3. 实现任务需求,修改/创建源代码文件",
|
||||
"4. 完成后运行 `python scripts/airplan.py --mode do --sub finish --task-id {tid} --result AirPlan/state/airdo/tasks/{tid}/result.json`",
|
||||
"",
|
||||
"## 约束",
|
||||
"- 只修改属于此任务的文件",
|
||||
"- 完成后必须运行 finish 命令",
|
||||
"- 遇到无法解决的问题时返回 blocked 状态",
|
||||
]
|
||||
prompt = "\n".join(prompt_parts).format(tid=tid, task_text=task_text, project_root=project_root)
|
||||
|
||||
instructions.append({
|
||||
"description": f"Do Worker: {tid}",
|
||||
"subagent_type": "general-purpose",
|
||||
"prompt": prompt,
|
||||
"run_in_background": True, # 关键:后台运行,Eng 不阻塞
|
||||
"taskId": tid,
|
||||
"taskText": task_text,
|
||||
})
|
||||
return instructions
|
||||
|
||||
|
||||
def handle_adr_invalidation(project_root: Path, adr_id: str) -> dict:
|
||||
"""P1-21: ADR 变更级联失效处理。
|
||||
|
||||
10步流程:
|
||||
1. 加载 task-graph.json
|
||||
2. 调用 invalidate_by_adr() 级联失效
|
||||
3. 冻结调度
|
||||
4. 中止进行中的相关 Worker
|
||||
5. 创建回滚快照(git tag)
|
||||
6. git revert 已合并的旧代码
|
||||
7. 写回更新后的 task-graph.json
|
||||
8. 等待 Arc 重新生成受影响部分的任务
|
||||
9. apply_delta() 吸收新任务
|
||||
10. 解冻调度
|
||||
"""
|
||||
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if not tg_json.exists():
|
||||
return {"error": "task-graph.json not found", "adrId": adr_id}
|
||||
|
||||
graph = TaskGraph.load(tg_json)
|
||||
delta = PlanDelta()
|
||||
|
||||
# 2-4: 级联失效
|
||||
report = graph.invalidate_by_adr(adr_id, delta)
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(ADR_INVALIDATION, {
|
||||
"adrId": adr_id,
|
||||
"invalidatedCompleted": report.invalidated_completed,
|
||||
"terminatedInProgress": report.terminated_in_progress,
|
||||
"cascadedDownstream": report.cascaded_downstream,
|
||||
})
|
||||
|
||||
# 4: 中止进行中的相关 Worker
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
terminated_workers = []
|
||||
for worker in list(state.get("activeWorkers", [])):
|
||||
if worker.get("taskId") in report.invalidated_task_ids:
|
||||
terminated_workers.append(worker["taskId"])
|
||||
state["activeWorkers"] = [
|
||||
w for w in state.get("activeWorkers", [])
|
||||
if w.get("taskId") not in report.invalidated_task_ids
|
||||
]
|
||||
|
||||
# 5: 创建回滚快照
|
||||
rollback_ref = _create_rollback_snapshot(project_root, report.invalidated_task_ids)
|
||||
report.rollback_ref = rollback_ref
|
||||
delta.rollback_ref = rollback_ref
|
||||
|
||||
# 6: git revert 已合并的旧代码(按 task_id 查找对应 commit)
|
||||
revert_results = _git_revert_invalidated(project_root, report.invalidated_task_ids)
|
||||
|
||||
# 6.5: 生成局部重规划请求(PartialReplanner)
|
||||
from air_runtime.partial_replanner import PartialReplanner
|
||||
replanner = PartialReplanner()
|
||||
partial_delta = replanner.replan(graph, report.invalidated_task_ids)
|
||||
replan_request_path = paths["plan_dir"] / f"replan-request-{session_stamp()}.json"
|
||||
atomic_json_write(replan_request_path, partial_delta.replan_request)
|
||||
|
||||
# 7: 写回更新后的 task-graph.json
|
||||
from air_runtime.modes.arc_mode import _export_task_graph_json
|
||||
_export_task_graph_json(graph, tg_json)
|
||||
|
||||
# 更新引擎状态
|
||||
state["dispatchFrozen"] = True
|
||||
state["adrInvalidationInProgress"] = {
|
||||
"adrId": adr_id,
|
||||
"startedAt": now_iso(),
|
||||
"invalidatedTaskIds": report.invalidated_task_ids,
|
||||
"rollbackRef": rollback_ref,
|
||||
}
|
||||
atomic_json_write(paths["state"], state)
|
||||
|
||||
return {
|
||||
"adrId": adr_id,
|
||||
"cascadeReport": {
|
||||
"invalidatedCompleted": report.invalidated_completed,
|
||||
"terminatedInProgress": report.terminated_in_progress,
|
||||
"cascadedDownstream": report.cascaded_downstream,
|
||||
"rollbackRef": rollback_ref,
|
||||
"invalidatedTaskIds": report.invalidated_task_ids,
|
||||
},
|
||||
"terminatedWorkers": terminated_workers,
|
||||
"revertResults": revert_results,
|
||||
"replanRequestPath": str(replan_request_path),
|
||||
"nextStep": "arc-replan-then-unfreeze",
|
||||
}
|
||||
|
||||
|
||||
def _create_rollback_snapshot(project_root: Path, invalidated_task_ids: list[str]) -> str:
|
||||
"""P1-21: 为失效任务创建 git tag 回滚点。"""
|
||||
import subprocess
|
||||
ref = f"airplan/adr-invalidate-{session_stamp()}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "tag", ref],
|
||||
cwd=project_root, capture_output=True, timeout=30,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ref
|
||||
|
||||
|
||||
def _git_revert_invalidated(project_root: Path, invalidated_task_ids: list[str]) -> list[dict]:
|
||||
"""P1-21: 尝试 git revert 已合并的失效任务对应的 commit。"""
|
||||
import subprocess
|
||||
results = []
|
||||
for tid in invalidated_task_ids:
|
||||
try:
|
||||
# 查找包含 task_id 的 commit
|
||||
r = subprocess.run(
|
||||
["git", "log", "--oneline", "--all", "--grep", tid, "-1"],
|
||||
cwd=project_root, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode == 0 and r.stdout.strip():
|
||||
commit_hash = r.stdout.strip().split()[0]
|
||||
rv = subprocess.run(
|
||||
["git", "revert", "--no-commit", commit_hash],
|
||||
cwd=project_root, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
results.append({"taskId": tid, "commit": commit_hash, "reverted": rv.returncode == 0})
|
||||
if rv.returncode == 0:
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"AirPlan: revert invalidated task {tid}"],
|
||||
cwd=project_root, capture_output=True, timeout=10,
|
||||
)
|
||||
else:
|
||||
results.append({"taskId": tid, "commit": None, "reverted": False, "reason": "no commit found"})
|
||||
except Exception as e:
|
||||
results.append({"taskId": tid, "commit": None, "reverted": False, "reason": str(e)})
|
||||
return results
|
||||
|
||||
|
||||
def unfreeze_after_replan(project_root: Path, new_task_graph_path: Path | None = None) -> dict:
|
||||
"""P1-21: Arc 重新生成受影响部分后,apply_delta + 解冻调度。"""
|
||||
paths = _paths(project_root)
|
||||
_ensure_dirs(paths)
|
||||
|
||||
tg_json = airplan_root(project_root) / "state" / "airarc" / "reviews" / "task-graph.json"
|
||||
if not tg_json.exists():
|
||||
return {"error": "task-graph.json not found"}
|
||||
|
||||
graph = TaskGraph.load(tg_json)
|
||||
|
||||
# 如果 Arc 生成了新的任务图,增量合并
|
||||
if new_task_graph_path and new_task_graph_path.exists():
|
||||
new_graph = TaskGraph.load(new_task_graph_path)
|
||||
delta = new_graph.diff(graph)
|
||||
graph.apply_delta(delta)
|
||||
|
||||
# 解冻
|
||||
graph.unfreeze_dispatch()
|
||||
from air_runtime.modes.arc_mode import _export_task_graph_json
|
||||
_export_task_graph_json(graph, tg_json)
|
||||
|
||||
# 更新引擎状态
|
||||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||||
state["dispatchFrozen"] = False
|
||||
adr_info = state.pop("adrInvalidationInProgress", {})
|
||||
atomic_json_write(paths["state"], state)
|
||||
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit(ADR_UNFREEZED, {"previousAdrInvalidation": adr_info})
|
||||
|
||||
return {"frozen": False, "readyTasks": graph.ready_tasks()}
|
||||
|
||||
|
||||
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']}")
|
||||
247
lib/air_runtime/modes/eng_orchestrator.py
Executable file
247
lib/air_runtime/modes/eng_orchestrator.py
Executable 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']}")
|
||||
238
lib/air_runtime/modes/merge_pipeline.py
Executable file
238
lib/air_runtime/modes/merge_pipeline.py
Executable 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.md(Phase 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_write(content 为 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
|
||||
1
lib/air_runtime/modes/ndb_mode.py
Executable file
1
lib/air_runtime/modes/ndb_mode.py
Executable file
@@ -0,0 +1 @@
|
||||
from air_runtime.modes.xdb_sdb_ndb_modes import ndb_main as main
|
||||
32
lib/air_runtime/modes/rvr_mode.py
Executable file
32
lib/air_runtime/modes/rvr_mode.py
Executable 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)}")
|
||||
63
lib/air_runtime/modes/sdb_mode.py
Executable file
63
lib/air_runtime/modes/sdb_mode.py
Executable 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}")
|
||||
62
lib/air_runtime/modes/sec_mode.py
Executable file
62
lib/air_runtime/modes/sec_mode.py
Executable 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)}")
|
||||
35
lib/air_runtime/modes/tst_mode.py
Executable file
35
lib/air_runtime/modes/tst_mode.py
Executable 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)}")
|
||||
44
lib/air_runtime/modes/xdb_mode.py
Executable file
44
lib/air_runtime/modes/xdb_mode.py
Executable 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}")
|
||||
97
lib/air_runtime/modes/xdb_sdb_ndb_modes.py
Executable file
97
lib/air_runtime/modes/xdb_sdb_ndb_modes.py
Executable 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)}")
|
||||
Reference in New Issue
Block a user