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