feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化

P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由
- GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg
- 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过

P1-GAP17: 事件 emit 规范化
- 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等)
- 全部 emit 调用替换字符串字面量为常量,零残留
- 30 个事件类型常量全部定义且唯一

P1-GAP18: 事件日志原子轮转
- emit 计数器每 128 次检查轮转,避免每次 emit 读文件
- 清除未使用的 _emit_with_completion/_pending_merge_complete
- 原子轮转: tempfile+os.replace 保证不损坏

eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr)
commands/do.md: 更新为全专家插件路由文档

全量测试: 69 通过, 0 失败

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirPlan
2026-06-12 15:56:44 +08:00
commit 6130478c96
73 changed files with 10622 additions and 0 deletions

491
test_p1_21.py Normal file
View File

@@ -0,0 +1,491 @@
#!/usr/bin/env python3
"""P1-21 ADR 变更级联失效 功能测试"""
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "lib"))
def test_task_node_adr_refs():
"""测试 TaskNode.adr_refs 字段"""
from air_runtime.task_graph import TaskNode
node = TaskNode(id="T-001", adr_refs=["ADR-0005", "ADR-0012"])
assert node.adr_refs == ["ADR-0005", "ADR-0012"]
# 默认为空列表
node2 = TaskNode(id="T-002")
assert node2.adr_refs == []
print("✓ TaskNode.adr_refs 测试通过")
return True
def test_invalidated_status():
"""测试 INVALIDATED 状态"""
from air_runtime.task_graph import TaskNode, TaskGraph
node = TaskNode(id="T-001", status="DONE", task="用 ffmpeg 实现解码器",
adr_refs=["ADR-0005"])
# 直接设置 INVALIDATED
node.status = "INVALIDATED"
assert node.status == "INVALIDATED"
# _update_node 允许 INVALIDATED 覆盖 DONE
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", status="DONE", task="旧描述", adr_refs=["ADR-0005"]))
graph._update_node(TaskNode(id="T-001", status="INVALIDATED", task="新描述", adr_refs=["ADR-0005"]))
assert graph.nodes["T-001"].status == "INVALIDATED"
# _update_node 仍然保护 DONE 不被普通修改覆盖
graph2 = TaskGraph()
graph2.add_node(TaskNode(id="T-002", status="DONE", task="旧描述"))
graph2._update_node(TaskNode(id="T-002", status="TODO", task="新描述"))
assert graph2.nodes["T-002"].status == "DONE" # 被保护,不允许降级为 TODO
print("✓ INVALIDATED 状态测试通过")
return True
def test_tasks_by_adr():
"""测试 ADR→任务溯源链查询"""
from air_runtime.task_graph import TaskGraph, TaskNode
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", task="ffmpeg 解码器", adr_refs=["ADR-0005"]))
graph.add_node(TaskNode(id="T-002", task="ffmpeg 编码器", adr_refs=["ADR-0005", "ADR-0010"]))
graph.add_node(TaskNode(id="T-003", task="UI 界面", adr_refs=["ADR-0008"]))
graph.add_node(TaskNode(id="T-004", task="日志模块", adr_refs=[]))
# ADR-0005 关联 T-001 和 T-002
result = graph.tasks_by_adr("ADR-0005")
assert len(result) == 2
assert {n.id for n in result} == {"T-001", "T-002"}
# ADR-0010 只关联 T-002
result2 = graph.tasks_by_adr("ADR-0010")
assert len(result2) == 1
assert result2[0].id == "T-002"
# 不存在的 ADR
result3 = graph.tasks_by_adr("ADR-9999")
assert len(result3) == 0
print("✓ ADR→任务溯源链查询测试通过")
return True
def test_invalidate_by_adr():
"""测试 ADR 变更级联失效"""
from air_runtime.task_graph import TaskGraph, TaskNode, Edge, PlanDelta
graph = TaskGraph()
# 已完成的 ffmpeg 任务
graph.add_node(TaskNode(id="T-001", status="DONE", task="ffmpeg 解码器", adr_refs=["ADR-0005"]))
graph.add_node(TaskNode(id="T-002", status="DONE", task="ffmpeg 编码器", adr_refs=["ADR-0005"]))
# 进行中的 ffmpeg 任务
graph.add_node(TaskNode(id="T-003", status="DISPATCHED", task="ffmpeg 流媒体", adr_refs=["ADR-0005"]))
# ADR 直接关联的 TODO 任务
graph.add_node(TaskNode(id="T-003b", status="TODO", task="ffmpeg 滤镜", adr_refs=["ADR-0005"]))
# 下游任务(依赖上面的 ffmpeg 模块)
graph.add_node(TaskNode(id="T-004", status="TODO", task="集成测试"))
graph.add_node(TaskNode(id="T-005", status="TODO", task="部署"))
# 无关任务
graph.add_node(TaskNode(id="T-006", status="TODO", task="UI 优化", adr_refs=["ADR-0008"]))
graph.add_edge(Edge(source="T-001", target="T-004"))
graph.add_edge(Edge(source="T-002", target="T-004"))
graph.add_edge(Edge(source="T-004", target="T-005"))
delta = PlanDelta()
report = graph.invalidate_by_adr("ADR-0005", delta)
# 已完成: T-001, T-002
assert report.invalidated_completed == 2
# 进行中: T-003
assert report.terminated_in_progress == 1
# 下游: T-004, T-005 (T-003b 是直接关联的 TODO不属于 cascaded_downstream)
# 注意T-003b 直接关联 ADR-0005作为 pending 标记为 INVALIDATED
# 但它不属于 cascaded_downstream级联的下游任务
# cascaded_downstream 仅计算 _find_downstream 返回的非 ADR 直接关联任务
assert report.cascaded_downstream == 2
# 总失效 = completed(2) + in_progress(1) + pending(1) + cascaded(2) = 6
total_invalidated = len(report.invalidated_task_ids)
assert total_invalidated == 6
# 验证状态已变为 INVALIDATED
assert graph.nodes["T-001"].status == "INVALIDATED"
assert graph.nodes["T-002"].status == "INVALIDATED"
assert graph.nodes["T-003"].status == "INVALIDATED"
assert graph.nodes["T-003b"].status == "INVALIDATED"
assert graph.nodes["T-004"].status == "INVALIDATED"
assert graph.nodes["T-005"].status == "INVALIDATED"
# 无关任务不受影响
assert graph.nodes["T-006"].status == "TODO"
# 调度已冻结
assert graph.dispatch_frozen == True
# ready_tasks 为空(冻结状态)
assert graph.ready_tasks() == []
print(f"✓ ADR 变更级联失效测试通过 (完成={report.invalidated_completed}, "
f"进行中={report.terminated_in_progress}, 下游={report.cascaded_downstream})")
return True
def test_dispatch_freeze_unfreeze():
"""测试调度冻结和解冻"""
from air_runtime.task_graph import TaskGraph, TaskNode
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", status="TODO", task="任务1"))
# 正常状态
assert graph.ready_tasks() == ["T-001"]
# 冻结
graph.dispatch_frozen = True
assert graph.ready_tasks() == []
# 解冻
graph.unfreeze_dispatch()
assert graph.dispatch_frozen == False
assert graph.ready_tasks() == ["T-001"]
print("✓ 调度冻结/解冻测试通过")
return True
def test_cascade_report():
"""测试 CascadeReport 数据结构"""
from air_runtime.task_graph import CascadeReport
report = CascadeReport(
invalidated_completed=2,
terminated_in_progress=1,
cascaded_downstream=3,
rollback_ref="airplan/adr-invalidate-2026-06-11",
invalidated_task_ids=["T-001", "T-002", "T-003", "T-004", "T-005", "T-006"],
)
assert report.invalidated_completed == 2
assert report.terminated_in_progress == 1
assert report.cascaded_downstream == 3
assert report.rollback_ref.startswith("airplan/")
assert len(report.invalidated_task_ids) == 6
print("✓ CascadeReport 测试通过")
return True
def test_plan_delta_rollback_ref():
"""测试 PlanDelta.rollback_ref"""
from air_runtime.task_graph import PlanDelta
delta = PlanDelta(rollback_ref="airplan/adr-invalidate-2026-06-11")
assert delta.rollback_ref == "airplan/adr-invalidate-2026-06-11"
# 默认为空字符串
delta2 = PlanDelta()
assert delta2.rollback_ref == ""
print("✓ PlanDelta.rollback_ref 测试通过")
return True
def test_rvr_invalidated_cleanup_check():
"""测试 AirRvr INVALIDATED 清理检查"""
from air_runtime.review_runtime import ReviewRuntime
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
rvr = ReviewRuntime(project_root)
# 无残留
result = rvr.check_invalidated_cleanup(["T-001", "T-002"])
assert result["cleaned"] == True
assert result["residualCount"] == 0
# 创建残留的 result 文件
result_dir = project_root / "AirPlan" / "state" / "airdo" / "tasks" / "T-001"
result_dir.mkdir(parents=True, exist_ok=True)
(result_dir / "result.json").write_text('{"taskId": "T-001", "status": "done"}')
result2 = rvr.check_invalidated_cleanup(["T-001"])
assert result2["cleaned"] == False
assert result2["residualCount"] == 1
assert result2["residualDetails"][0]["taskId"] == "T-001"
print("✓ AirRvr INVALIDATED 清理检查测试通过")
return True
def test_eng_handle_adr_invalidation():
"""测试 Eng ADR 失效处理入口"""
from air_runtime.modes.eng_mode import handle_adr_invalidation
import json
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
# 初始化引擎
from air_runtime.modes.eng_mode import enter_engine
enter_engine(project_root)
# 创建 task-graph.json含 ADR 引用)
arc_dir = project_root / "AirPlan" / "state" / "airarc" / "reviews"
arc_dir.mkdir(parents=True, exist_ok=True)
from air_runtime.task_graph import TaskGraph, TaskNode, Edge
from air_runtime.modes.arc_mode import _export_task_graph_json
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", status="DONE", task="ffmpeg 解码器", adr_refs=["ADR-0005"]))
graph.add_node(TaskNode(id="T-002", status="TODO", task="集成测试"))
graph.add_edge(Edge(source="T-001", target="T-002"))
_export_task_graph_json(graph, arc_dir / "task-graph.json")
# 执行 ADR 失效
result = handle_adr_invalidation(project_root, "ADR-0005")
assert "cascadeReport" in result
assert result["cascadeReport"]["invalidatedCompleted"] == 1
assert result["cascadeReport"]["cascadedDownstream"] == 1
assert result["nextStep"] == "arc-replan-then-unfreeze"
# 验证 task-graph.json 已更新
updated = TaskGraph.load(arc_dir / "task-graph.json")
assert updated.nodes["T-001"].status == "INVALIDATED"
assert updated.dispatch_frozen == True
print("✓ Eng ADR 失效处理测试通过")
return True
def test_eng_unfreeze_after_replan():
"""测试 Eng 解冻调度"""
from air_runtime.modes.eng_mode import handle_adr_invalidation, unfreeze_after_replan
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
from air_runtime.modes.eng_mode import enter_engine
enter_engine(project_root)
arc_dir = project_root / "AirPlan" / "state" / "airarc" / "reviews"
arc_dir.mkdir(parents=True, exist_ok=True)
from air_runtime.task_graph import TaskGraph, TaskNode
from air_runtime.modes.arc_mode import _export_task_graph_json
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", status="DONE", task="ffmpeg 解码器", adr_refs=["ADR-0005"]))
graph.dispatch_frozen = True
_export_task_graph_json(graph, arc_dir / "task-graph.json")
# 解冻
result = unfreeze_after_replan(project_root)
assert result["frozen"] == False
# 验证 task-graph.json 已解冻
updated = TaskGraph.load(arc_dir / "task-graph.json")
assert updated.dispatch_frozen == False
print("✓ Eng 解冻调度测试通过")
return True
def test_ffmpeg_to_gstreamer_scenario():
"""完整场景测试ffmpeg → gstreamer 架构变更"""
from air_runtime.task_graph import TaskGraph, TaskNode, Edge, PlanDelta
# 初始状态ffmpeg 架构
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", status="DONE", task="用 ffmpeg 实现视频解码器",
files_dirs="src/decoder.cpp", adr_refs=["ADR-0005"]))
graph.add_node(TaskNode(id="T-002", status="DONE", task="用 ffmpeg 实现音频解码器",
files_dirs="src/audio_decoder.cpp", adr_refs=["ADR-0005"]))
graph.add_node(TaskNode(id="T-003", status="DONE", task="ffmpeg 流媒体传输",
files_dirs="src/streaming.cpp", adr_refs=["ADR-0005", "ADR-0006"]))
graph.add_node(TaskNode(id="T-004", status="TODO", task="解码器集成测试",
files_dirs="tests/decoder_test.cpp"))
graph.add_node(TaskNode(id="T-005", status="TODO", task="流媒体集成测试",
files_dirs="tests/streaming_test.cpp"))
graph.add_node(TaskNode(id="T-006", status="TODO", task="部署播放器",
files_dirs="deploy/"))
# 无关任务
graph.add_node(TaskNode(id="T-007", status="DONE", task="用户界面",
files_dirs="src/ui/", adr_refs=["ADR-0008"]))
graph.add_node(TaskNode(id="T-008", status="TODO", task="UI 测试",
files_dirs="tests/ui_test.cpp"))
graph.add_edge(Edge(source="T-001", target="T-004"))
graph.add_edge(Edge(source="T-002", target="T-004"))
graph.add_edge(Edge(source="T-003", target="T-005"))
graph.add_edge(Edge(source="T-004", target="T-006"))
graph.add_edge(Edge(source="T-005", target="T-006"))
graph.add_edge(Edge(source="T-007", target="T-008"))
# ADR-0005 变更ffmpeg → gstreamer
delta = PlanDelta(rollback_ref="airplan/adr-invalidate-snapshot-001")
report = graph.invalidate_by_adr("ADR-0005", delta)
# T-001, T-002, T-003 完成 → INVALIDATED
assert report.invalidated_completed == 3
# T-004, T-005, T-006 下游 → INVALIDATED
assert report.cascaded_downstream == 3
total_invalidated = len(report.invalidated_task_ids)
assert total_invalidated == 6 # T-001~T-006 全部失效
# 无关任务不受影响
assert graph.nodes["T-007"].status == "DONE"
assert graph.nodes["T-008"].status == "TODO"
# 调度冻结
assert graph.dispatch_frozen == True
assert graph.ready_tasks() == []
# 模拟 Arc 重新生成 gstreamer 任务后解冻
graph.add_node(TaskNode(id="T-101", status="TODO", task="用 gstreamer 实现视频解码器",
files_dirs="src/decoder.cpp", adr_refs=["ADR-0005"]))
graph.add_node(TaskNode(id="T-102", status="TODO", task="用 gstreamer 实现音频解码器",
files_dirs="src/audio_decoder.cpp", adr_refs=["ADR-0005"]))
graph.unfreeze_dispatch()
assert graph.dispatch_frozen == False
# 新任务可以派发
ready = [tid for tid, n in graph.nodes.items() if n.status == "TODO" and n.in_degree == 0]
assert "T-101" in ready
assert "T-102" in ready
# T-008 有入度(依赖 T-007不在 ready 中
assert "T-008" not in ready
print(f"✓ ffmpeg→gstreamer 完整场景测试通过 (失效={total_invalidated}, 新任务可派发)")
return True
def test_apply_full_replace_preserves_dispatched():
"""测试 apply_full_replace 保留 DISPATCHED 状态(不含 INVALIDATED"""
from air_runtime.task_graph import TaskGraph, TaskNode, Edge
old_graph = TaskGraph()
old_graph.add_node(TaskNode(id="T-001", status="DONE", task="已完成"))
old_graph.add_node(TaskNode(id="T-002", status="DISPATCHED", task="进行中"))
old_graph.add_node(TaskNode(id="T-003", status="INVALIDATED", task="已失效"))
new_nodes = [
TaskNode(id="T-001", status="TODO", task="已完成"),
TaskNode(id="T-002", status="TODO", task="进行中"),
TaskNode(id="T-003", status="TODO", task="已失效"),
]
old_graph.apply_full_replace(new_nodes, [])
# DONE 和 DISPATCHED 被保留
assert old_graph.nodes["T-001"].status == "DONE"
assert old_graph.nodes["T-002"].status == "DISPATCHED"
# INVALIDATED 不被保留(全量替换时重置为 TODO
assert old_graph.nodes["T-003"].status == "TODO"
print("✓ apply_full_replace 保留 DONE/DISPATCHED 测试通过")
return True
def test_git_revert_with_real_repo():
"""测试真实 git 仓库环境下的 revert"""
import subprocess
from air_runtime.modes.eng_mode import handle_adr_invalidation
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
# 初始化 git 仓库
subprocess.run(["git", "init"], cwd=project_root, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=project_root, capture_output=True)
subprocess.run(["git", "config", "user.name", "Test"], cwd=project_root, capture_output=True)
# 创建文件并提交(模拟 T-001 的代码)
(project_root / "src").mkdir()
(project_root / "src" / "decoder.cpp").write_text("// ffmpeg decoder")
subprocess.run(["git", "add", "."], cwd=project_root, capture_output=True)
subprocess.run(["git", "commit", "-m", "feat: T-001 ffmpeg decoder"], cwd=project_root, capture_output=True)
# 初始化引擎和 task-graph
from air_runtime.modes.eng_mode import enter_engine
enter_engine(project_root)
arc_dir = project_root / "AirPlan" / "state" / "airarc" / "reviews"
arc_dir.mkdir(parents=True, exist_ok=True)
from air_runtime.task_graph import TaskGraph, TaskNode
from air_runtime.modes.arc_mode import _export_task_graph_json
graph = TaskGraph()
graph.add_node(TaskNode(id="T-001", status="DONE", task="ffmpeg 解码器", adr_refs=["ADR-0005"]))
_export_task_graph_json(graph, arc_dir / "task-graph.json")
# 执行 ADR 失效
result = handle_adr_invalidation(project_root, "ADR-0005")
# 验证 revert 结果
assert "revertResults" in result
# 应该找到一个包含 "T-001" 的 commit 并尝试 revert
revert = result["revertResults"][0]
assert revert["taskId"] == "T-001"
# commit 应该被找到
assert revert["commit"] is not None
print("✓ 真实 git 环境下 revert 测试通过")
return True
def main():
print("=" * 50)
print("P1-21 ADR 变更级联失效 功能测试")
print("=" * 50)
tests = [
("TaskNode.adr_refs", test_task_node_adr_refs),
("INVALIDATED 状态", test_invalidated_status),
("ADR→任务溯源链", test_tasks_by_adr),
("级联失效", test_invalidate_by_adr),
("调度冻结/解冻", test_dispatch_freeze_unfreeze),
("CascadeReport", test_cascade_report),
("PlanDelta.rollback_ref", test_plan_delta_rollback_ref),
("AirRvr 清理检查", test_rvr_invalidated_cleanup_check),
("Eng ADR 失效处理", test_eng_handle_adr_invalidation),
("Eng 解冻调度", test_eng_unfreeze_after_replan),
("apply_full_replace 保留状态", test_apply_full_replace_preserves_dispatched),
("真实 git revert", test_git_revert_with_real_repo),
("ffmpeg→gstreamer 场景", test_ffmpeg_to_gstreamer_scenario),
]
passed = 0
failed = 0
for name, test_fn in tests:
try:
test_fn()
passed += 1
except Exception as e:
print(f"{name} 失败: {e}")
import traceback
traceback.print_exc()
failed += 1
print("=" * 50)
print(f"测试结果: {passed} 通过, {failed} 失败")
print("=" * 50)
return failed == 0
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)