- 新增 ChangeClassifier(爆炸半径分类:IMPLEMENTATION/INTERFACE/GLOBAL_CONSTRAINT) - 新增 ImpactPropagator(BFS影响传播:IMPACTED/BOUNDARY/SAFE差异化标记) - task_graph.py:invalidate_by_adr()差异化失效 + CascadeReport扩展字段(向后兼容) - eng_mode.py:三阶段差异化流程(分类→传播→失效→git操作→验证任务→重规划) - eng_mode.py:_git_squash_merge_and_tag() + Phase 7 集成 - do_mode.py:_ensure_all_committed() Worker git操作强制 - adr_watcher.py:内容快照 + get_content_for_classification() - events.py:ADR_CLASSIFIED/IMPACT_PROPAGATED/BOUNDARY_VERIFICATION_GENERATED - partial_replanner.py:replan_with_constraints() + generate_verification_tasks() - project_bootstrap.py:ensure_git_initialized() Arc规划前检测 - test_p1_21_phase2.py:21个新测试,95个全量测试0失败 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1137 lines
46 KiB
Python
Executable File
1137 lines
46 KiB
Python
Executable File
"""
|
||
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_CLASSIFIED, ADR_INVALIDATION, IMPACT_PROPAGATED, \
|
||
BOUNDARY_VERIFICATION_GENERATED, 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) -> tuple[list, object | None]:
|
||
"""P1-21: 检查 ADR 文件变更,返回 (需要级联失效的变更列表, ADRWatcher 实例)。
|
||
|
||
3.2.17: 同时持久化内容快照供 ChangeClassifier 爆炸半径分类使用。
|
||
"""
|
||
from air_runtime.adr_watcher import ADRWatcher, ADRChange
|
||
adr_dir = project_root / "AirPlan" / "docs" / "architecture" / "adr"
|
||
if not adr_dir.exists():
|
||
return [], None
|
||
|
||
watcher = ADRWatcher(adr_dir)
|
||
# 从引擎状态恢复已知 hash 和内容快照
|
||
known = state.get("adrWatcherHashes", {})
|
||
watcher._known_hashes = dict(known)
|
||
watcher._content_snapshots = dict(state.get("adrWatcherContentSnapshots", {}))
|
||
|
||
# 首次无 snapshot → 先初始化
|
||
if not known:
|
||
watcher.snapshot()
|
||
state["adrWatcherHashes"] = dict(watcher._known_hashes)
|
||
state["adrWatcherContentSnapshots"] = dict(watcher._content_snapshots)
|
||
return [], watcher
|
||
|
||
changes = watcher.detect_changes()
|
||
# 持久化更新后的 hash 和内容快照
|
||
state["adrWatcherHashes"] = dict(watcher._known_hashes)
|
||
state["adrWatcherContentSnapshots"] = dict(watcher._content_snapshots)
|
||
|
||
# 只返回需要级联失效的变更
|
||
actionable = [c for c in changes if c.kind in ("superseded", "modified", "deleted")]
|
||
return actionable, watcher
|
||
|
||
|
||
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
|
||
|
||
# 资源压力检测 (Unix only; Windows 上不可用)
|
||
resource_pressure = False
|
||
if hasattr(os, "getloadavg"):
|
||
try:
|
||
load = os.getloadavg()[0]
|
||
cpu_count = os.cpu_count() or 4
|
||
resource_pressure = load > cpu_count * 2
|
||
except OSError:
|
||
pass
|
||
|
||
# P1-21: ADR 变更自动检测(3.2.17 差异化流程)
|
||
adr_changes, adr_watcher = _detect_adr_changes(project_root, state)
|
||
adr_invalidation_results = []
|
||
if adr_changes:
|
||
for change in adr_changes:
|
||
if change.kind in ("superseded", "modified", "deleted"):
|
||
log = EventLog(event_log_path(project_root))
|
||
log.emit(ADR_CHANGE_DETECTED, {
|
||
"adrId": change.adr_id, "kind": change.kind,
|
||
})
|
||
# 直接调用差异化失效处理
|
||
inv_result = handle_adr_invalidation(
|
||
project_root, change.adr_id,
|
||
adr_change=change, watcher=adr_watcher,
|
||
)
|
||
adr_invalidation_results.append(inv_result)
|
||
interventions.append({
|
||
"adrId": change.adr_id,
|
||
"reason": f"adr-{change.kind}",
|
||
"action": "invalidate-by-adr",
|
||
"blastRadius": inv_result.get("blastRadius", ""),
|
||
"invalidatedCount": len(inv_result.get("cascadeReport", {}).get("invalidatedTaskIds", [])),
|
||
})
|
||
|
||
# 新增:检查 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")],
|
||
"adrInvalidationResults": adr_invalidation_results,
|
||
"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)})
|
||
|
||
squash_result = {}
|
||
|
||
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)
|
||
|
||
# Phase 7: git squash merge + tag(3.2.5)
|
||
squash_result = {}
|
||
if status == "done":
|
||
wt_path = project_root / ".git" / "worktrees" / f"air-{task_id}"
|
||
squash_result = _git_squash_merge_and_tag(
|
||
project_root, task_id,
|
||
worktree_path=wt_path if wt_path.exists() else None,
|
||
)
|
||
|
||
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],
|
||
"squashResult": squash_result,
|
||
"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,
|
||
adr_change=None, watcher=None) -> dict:
|
||
"""P1-21 / 3.2.17: ADR 变更级联失效处理(三阶段差异化流程)。
|
||
|
||
Phase 1: ChangeClassifier 爆炸半径分类 → ImpactPropagator BFS 传播 → 差异化失效
|
||
Phase 2: Git 操作(revert IMPACTED+DONE, 清理 worktree, BOUNDARY 验证任务生成)
|
||
Phase 3: 提取 SAFE 接口约束 → 局部重规划 → 解冻
|
||
|
||
当 adr_change/watcher 为 None 时回退到旧统一失效行为(向后兼容)。
|
||
"""
|
||
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()
|
||
log = EventLog(event_log_path(project_root))
|
||
|
||
# ── Phase 1: 分类 → 传播 → 失效 ──
|
||
blast_radius = ""
|
||
boundary_task_ids: list[str] = []
|
||
impact_labels: dict[str, str] | None = None
|
||
|
||
if adr_change is not None and watcher is not None:
|
||
# 3.2.17 差异化流程
|
||
from air_runtime.change_classifier import ChangeClassifier
|
||
from air_runtime.impact_propagator import ImpactPropagator, ImpactLabel
|
||
|
||
old_content, new_content = watcher.get_content_for_classification(adr_change)
|
||
classifier = ChangeClassifier()
|
||
classification = classifier.classify(adr_change.adr_id, old_content, new_content)
|
||
blast_radius = classification.blast_radius.value
|
||
log.emit(ADR_CLASSIFIED, {
|
||
"adrId": adr_change.adr_id,
|
||
"blastRadius": blast_radius,
|
||
"reason": classification.reason,
|
||
})
|
||
|
||
propagator = ImpactPropagator()
|
||
propagation_result = propagator.propagate(graph, adr_change.adr_id, classification.blast_radius)
|
||
log.emit(IMPACT_PROPAGATED, {
|
||
"adrId": adr_change.adr_id,
|
||
"blastRadius": blast_radius,
|
||
"impactedCount": len(propagation_result.impacted),
|
||
"boundaryCount": len(propagation_result.boundary),
|
||
"safeCount": len(propagation_result.safe),
|
||
})
|
||
|
||
impact_labels = {}
|
||
for nid in propagation_result.impacted:
|
||
impact_labels[nid] = ImpactLabel.IMPACTED.value
|
||
for nid in propagation_result.boundary:
|
||
impact_labels[nid] = ImpactLabel.BOUNDARY.value
|
||
for nid in propagation_result.safe:
|
||
impact_labels[nid] = ImpactLabel.SAFE.value
|
||
|
||
# 级联失效
|
||
report = graph.invalidate_by_adr(adr_id, delta, impact_labels=impact_labels)
|
||
report.blast_radius = blast_radius
|
||
log.emit(ADR_INVALIDATION, {
|
||
"adrId": adr_id,
|
||
"blastRadius": blast_radius or "uniform",
|
||
"invalidatedCompleted": report.invalidated_completed,
|
||
"terminatedInProgress": report.terminated_in_progress,
|
||
"cascadedDownstream": report.cascaded_downstream,
|
||
"boundaryTaskCount": len(report.boundary_task_ids),
|
||
})
|
||
|
||
# ── Phase 1.5: 终止 IMPACTED Worker ──
|
||
paths = _paths(project_root)
|
||
_ensure_dirs(paths)
|
||
state = safe_json_load(paths["state"]) or _init_state(project_root)
|
||
terminated_workers = []
|
||
impacted_ids = set(report.invalidated_task_ids)
|
||
for worker in list(state.get("activeWorkers", [])):
|
||
if worker.get("taskId") in impacted_ids:
|
||
terminated_workers.append(worker["taskId"])
|
||
state["activeWorkers"] = [
|
||
w for w in state.get("activeWorkers", [])
|
||
if w.get("taskId") not in impacted_ids
|
||
]
|
||
|
||
# ── Phase 2: Git 操作 ──
|
||
# 2a: 创建回滚快照
|
||
rollback_ref = _create_rollback_snapshot(project_root, list(impacted_ids))
|
||
report.rollback_ref = rollback_ref
|
||
delta.rollback_ref = rollback_ref
|
||
|
||
# 2b: git revert IMPACTED+DONE 任务的 commit
|
||
revert_results = _git_revert_invalidated(project_root, list(impacted_ids))
|
||
|
||
# 2c: 清理 IMPACTED+DISPATCHED 的 worktree
|
||
worktree_cleanup_results = _cleanup_impacted_worktrees(project_root, list(impacted_ids))
|
||
|
||
# 2d: 为 BOUNDARY+DONE 任务生成验证任务
|
||
verification_delta = PlanDelta()
|
||
if report.boundary_task_ids:
|
||
from air_runtime.partial_replanner import PartialReplanner
|
||
replanner = PartialReplanner()
|
||
verification_delta = replanner.generate_verification_tasks(graph, report.boundary_task_ids)
|
||
if verification_delta.added_tasks:
|
||
log.emit(BOUNDARY_VERIFICATION_GENERATED, {
|
||
"adrId": adr_id,
|
||
"boundaryTaskIds": report.boundary_task_ids,
|
||
"verificationTaskIds": [n.id for n in verification_delta.added_tasks],
|
||
})
|
||
|
||
# ── Phase 3: 局部重规划 ──
|
||
from air_runtime.partial_replanner import PartialReplanner
|
||
replanner = PartialReplanner()
|
||
|
||
# 3a: 提取 SAFE+DONE 任务的接口约束
|
||
safe_interfaces = _extract_safe_interfaces(graph)
|
||
|
||
# 3b: 带冻结接口约束的局部重规划
|
||
invalidated_ids = report.invalidated_task_ids
|
||
if safe_interfaces:
|
||
partial_delta = replanner.replan_with_constraints(
|
||
graph, invalidated_ids, safe_interfaces,
|
||
)
|
||
else:
|
||
partial_delta = replanner.replan(graph, invalidated_ids)
|
||
|
||
# 3c: 合并验证任务到重规划结果
|
||
for vn in verification_delta.added_tasks:
|
||
partial_delta.added_tasks.append(vn)
|
||
for ve in verification_delta.edge_changes.added:
|
||
partial_delta.edge_changes.added.append(ve)
|
||
|
||
# 3d: 应用 delta
|
||
graph.apply_delta(partial_delta)
|
||
|
||
# 保存局部重规划请求
|
||
replan_request_path = paths["plan_dir"] / f"replan-request-{session_stamp()}.json"
|
||
atomic_json_write(replan_request_path, partial_delta.replan_request)
|
||
|
||
# ── 写回 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(),
|
||
"blastRadius": blast_radius,
|
||
"invalidatedTaskIds": invalidated_ids,
|
||
"boundaryTaskIds": report.boundary_task_ids,
|
||
"rollbackRef": rollback_ref,
|
||
}
|
||
atomic_json_write(paths["state"], state)
|
||
|
||
return {
|
||
"adrId": adr_id,
|
||
"blastRadius": blast_radius or "uniform",
|
||
"cascadeReport": {
|
||
"invalidatedCompleted": report.invalidated_completed,
|
||
"terminatedInProgress": report.terminated_in_progress,
|
||
"cascadedDownstream": report.cascaded_downstream,
|
||
"rollbackRef": rollback_ref,
|
||
"invalidatedTaskIds": invalidated_ids,
|
||
"boundaryTaskIds": report.boundary_task_ids,
|
||
},
|
||
"terminatedWorkers": terminated_workers,
|
||
"revertResults": revert_results,
|
||
"worktreeCleanupResults": worktree_cleanup_results,
|
||
"verificationTaskCount": len(verification_delta.added_tasks),
|
||
"safeInterfaceCount": len(safe_interfaces),
|
||
"replanRequestPath": str(replan_request_path),
|
||
"nextStep": "arc-replan-then-unfreeze",
|
||
}
|
||
|
||
|
||
def _extract_safe_interfaces(graph: TaskGraph) -> list:
|
||
"""3.2.17 Phase 3: 提取 SAFE+DONE 任务的接口约束,供重规划使用。"""
|
||
from air_runtime.partial_replanner import Interface
|
||
from air_runtime.impact_propagator import ImpactLabel
|
||
|
||
interfaces = []
|
||
for nid, node in graph.nodes.items():
|
||
label = node.meta.get("impact_label", "")
|
||
if label == ImpactLabel.SAFE.value and node.status == "DONE":
|
||
if node.write_set or node.adr_refs:
|
||
interfaces.append(Interface(
|
||
task_id=node.id,
|
||
write_set=list(node.write_set),
|
||
adr_refs=list(node.adr_refs),
|
||
))
|
||
return interfaces
|
||
|
||
|
||
def _cleanup_impacted_worktrees(project_root: Path, impacted_ids: list[str]) -> list[dict]:
|
||
"""3.2.17 Phase 2c: 清理 IMPACTED+DISPATCHED 任务的 worktree。"""
|
||
import subprocess
|
||
results = []
|
||
wt_root = project_root / ".git" / "worktrees"
|
||
if not wt_root.exists():
|
||
return results
|
||
|
||
for tid in impacted_ids:
|
||
wt_dir = wt_root / f"air-{tid}"
|
||
if wt_dir.exists():
|
||
try:
|
||
subprocess.run(
|
||
["git", "worktree", "remove", str(wt_dir), "--force"],
|
||
cwd=project_root, capture_output=True, text=True, timeout=30,
|
||
)
|
||
results.append({"taskId": tid, "worktree": str(wt_dir), "removed": True})
|
||
except Exception as e:
|
||
results.append({"taskId": tid, "worktree": str(wt_dir), "removed": False, "error": str(e)})
|
||
return results
|
||
|
||
|
||
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。
|
||
|
||
优先使用 air/done/{task_id} tag 定位 commit,回退到 commit message 搜索。
|
||
"""
|
||
import subprocess
|
||
results = []
|
||
for tid in invalidated_task_ids:
|
||
try:
|
||
commit_hash = None
|
||
# 优先尝试 air/done/{task_id} tag
|
||
tag_name = f"air/done/{tid}"
|
||
tag_check = subprocess.run(
|
||
["git", "rev-parse", "--verify", f"refs/tags/{tag_name}"],
|
||
cwd=project_root, capture_output=True, text=True, timeout=10,
|
||
)
|
||
if tag_check.returncode == 0:
|
||
commit_hash = tag_check.stdout.strip()
|
||
else:
|
||
# 回退到 commit message 搜索
|
||
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]
|
||
|
||
if commit_hash:
|
||
rv = subprocess.run(
|
||
["git", "revert", "--no-commit", commit_hash],
|
||
cwd=project_root, capture_output=True, text=True, timeout=30,
|
||
)
|
||
if rv.returncode == 0:
|
||
subprocess.run(
|
||
["git", "commit", "-m", f"AirPlan: revert invalidated task {tid}"],
|
||
cwd=project_root, capture_output=True, timeout=10,
|
||
)
|
||
results.append({"taskId": tid, "commit": commit_hash, "reverted": rv.returncode == 0})
|
||
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 _git_squash_merge_and_tag(project_root: Path, task_id: str,
|
||
worktree_path: Path | None = None) -> dict:
|
||
"""3.2.5 Phase 7: git squash merge + tag。
|
||
|
||
步骤:
|
||
1. git merge --squash <worktree-branch>
|
||
2. git commit -m "AirPlan: done {task_id}"
|
||
3. git tag air/done/{task_id}
|
||
4. git worktree remove <worktree-path>
|
||
5. git rev-parse --verify refs/tags/air/done/{task_id} 验证
|
||
"""
|
||
import subprocess
|
||
tag_name = f"air/done/{task_id}"
|
||
result = {"taskId": task_id, "squashed": False, "tagged": False, "worktreeCleaned": False}
|
||
|
||
try:
|
||
# 1. 尝试 squash merge worktree 分支到当前分支
|
||
branch_name = f"air/do/{task_id}"
|
||
# 检查 worktree 分支是否存在
|
||
branch_check = subprocess.run(
|
||
["git", "rev-parse", "--verify", branch_name],
|
||
cwd=project_root, capture_output=True, text=True, timeout=10,
|
||
)
|
||
if branch_check.returncode == 0:
|
||
# 分支存在,执行 squash merge
|
||
mr = subprocess.run(
|
||
["git", "merge", "--squash", branch_name],
|
||
cwd=project_root, capture_output=True, text=True, timeout=30,
|
||
)
|
||
if mr.returncode == 0:
|
||
# 2. Commit
|
||
subprocess.run(
|
||
["git", "commit", "-m", f"AirPlan: done {task_id}"],
|
||
cwd=project_root, capture_output=True, timeout=10,
|
||
)
|
||
result["squashed"] = True
|
||
else:
|
||
# 无独立分支,尝试直接基于现有 HEAD commit 打 tag
|
||
result["squashed"] = True # 假设已在主分支上提交
|
||
|
||
# 3. Tag
|
||
subprocess.run(
|
||
["git", "tag", "-f", tag_name, "HEAD"],
|
||
cwd=project_root, capture_output=True, timeout=10,
|
||
)
|
||
result["tagged"] = True
|
||
|
||
# 4. 清理 worktree
|
||
if worktree_path and worktree_path.exists():
|
||
subprocess.run(
|
||
["git", "worktree", "remove", str(worktree_path), "--force"],
|
||
cwd=project_root, capture_output=True, text=True, timeout=30,
|
||
)
|
||
result["worktreeCleaned"] = True
|
||
|
||
# 5. 验证 tag
|
||
verify = subprocess.run(
|
||
["git", "rev-parse", "--verify", f"refs/tags/{tag_name}"],
|
||
cwd=project_root, capture_output=True, text=True, timeout=10,
|
||
)
|
||
result["tagVerified"] = verify.returncode == 0
|
||
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
|
||
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']}")
|