from __future__ import annotations import json from pathlib import Path from typing import Dict, Iterable, List from .contracts import DEFAULT_REPAIR_POLICY, RepairAttempt, WorkerResult, now_iso from .paths import ( airdo_root, aireng_root, debug_log_path, gui_debug_log_path, plan_path, todo_path, ) def _json_load(path: Path) -> Dict[str, object]: if not path.exists(): return {} return json.loads(path.read_text(encoding="utf-8-sig")) def _json_dump(path: Path, payload: Dict[str, object]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") def _ordered_unique(items: Iterable[str]) -> List[str]: seen = set() ordered: List[str] = [] for item in items: cleaned = str(item).strip() if not cleaned or cleaned in seen: continue seen.add(cleaned) ordered.append(cleaned) return ordered def normalize_repair_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]: merged = dict(DEFAULT_REPAIR_POLICY) if policy: merged.update(policy) merged["enabled"] = bool(merged.get("enabled", True)) merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True)) merged["autoPrepareWorker"] = bool(merged.get("autoPrepareWorker", True)) merged["triggerValidationStatuses"] = _ordered_unique( str(item).lower() for item in list(merged.get("triggerValidationStatuses", [])) ) try: max_attempts = int(merged.get("maxAttemptsPerTask", 2) or 2) except (TypeError, ValueError): max_attempts = 2 merged["maxAttemptsPerTask"] = max(1, max_attempts) requeue_status = str(merged.get("requeueTodoStatus", "DOING")).strip().upper() if requeue_status not in {"TODO", "DOING"}: requeue_status = "DOING" merged["requeueTodoStatus"] = requeue_status return merged def load_repair_policy(project_root: Path) -> Dict[str, object]: state_path = aireng_root(project_root) / "state.json" state = _json_load(state_path) return normalize_repair_policy(state.get("repairPolicy", {})) def _repair_root(project_root: Path) -> Path: return aireng_root(project_root) / "repairs" def _repair_queue_path(project_root: Path) -> Path: return aireng_root(project_root) / "repair-queue.md" def _repair_attempt_path(project_root: Path, task_id: str, repair_id: str) -> Path: return _repair_root(project_root) / task_id / repair_id / "attempt.json" def _repair_brief_path(project_root: Path, task_id: str, repair_id: str) -> Path: return _repair_root(project_root) / task_id / repair_id / "repair-brief.md" def _worker_repair_brief_path(project_root: Path, task_id: str) -> Path: return airdo_root(project_root) / "tasks" / task_id / "repair-brief.md" def _repair_reasons(result: WorkerResult, policy: Dict[str, object]) -> List[str]: reasons: List[str] = [] if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)): reasons.append("blocked worker result requires automatic repair") tracked_statuses = { str(item).lower() for item in list(policy.get("triggerValidationStatuses", [])) } failed_validations = [ item for item in result.validations if item.status.lower() in tracked_statuses ] if failed_validations: reasons.append( "validation failures: " + ", ".join(f"{item.kind}:{item.status}" for item in failed_validations) ) return reasons def _build_repair_brief(project_root: Path, result: WorkerResult, attempt: RepairAttempt) -> str: debug_session_ids = ", ".join(f"`{item}`" for item in attempt.debug_session_ids) or "`none`" xdb_session_ids = ", ".join(f"`{item.session_id}`" for item in result.xdb_sessions) or "`none`" validations = ( "; ".join( f"{item.kind}:{item.status}" + (f" ({item.command})" if item.command else "") for item in result.validations ) or "none" ) evidence = ", ".join(f"`{item}`" for item in result.evidence_paths) or "`none`" blockers = ", ".join(result.blockers) or "none" risks = ", ".join(result.risks) or "none" return "\n".join( [ f"# Auto Repair Brief: {attempt.repair_id}", "", f"- Task ID: `{result.task_id}`", f"- Attempt Index: `{attempt.attempt_index}`", f"- Source Status: `{result.status}`", f"- Reason: {attempt.reason}", f"- Debug Sessions: {debug_session_ids}", f"- Source Result Path: `{attempt.source_result_path}`", "", "## Required Context", "", f"- Load `{project_root / 'AirPlan' / 'AGENTS.md'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'adr'}`, `{project_root / 'AirPlan' / 'docs' / 'architecture' / 'c4' / 'module.md'}`, `{plan_path(project_root)}`, `{todo_path(project_root)}`, `{debug_log_path(project_root)}`, and `{gui_debug_log_path(project_root)}` when present.", "- Read the referenced AirDbg request/session artifacts before changing code.", "- If this task has AirXDB evidence, read the GUI screenshots and report before changing code.", "- Continue repairing automatically. Do not stop at the existence of a debug request.", "- Only return `blocked` again when the repair budget is exhausted or a real user decision is required.", "", "## Failure Snapshot", "", f"- Summary: {result.summary}", f"- Validations: {validations}", f"- Blockers: {blockers}", f"- Evidence: {evidence}", f"- XDB Sessions: {xdb_session_ids}", f"- Risks: {risks}", "", "## Repair Goal", "", "- Modify the scoped code until the original validation path passes again.", "- Keep the same task id and reuse the existing debug session id in the final repaired result.", "- Update document sync content if the final fix changes architecture or execution rules.", "", f"- Project Root: `{project_root}`", ] ) + "\n" def _write_repair_attempt_artifacts( project_root: Path, result: WorkerResult, attempt: RepairAttempt ) -> RepairAttempt: attempt_path = Path(attempt.state_path) brief_path = Path(attempt.repair_brief_path) worker_brief_path = Path(attempt.worker_brief_path) brief_content = _build_repair_brief(project_root, result, attempt) brief_path.parent.mkdir(parents=True, exist_ok=True) brief_path.write_text(brief_content, encoding="utf-8") worker_brief_path.parent.mkdir(parents=True, exist_ok=True) worker_brief_path.write_text(brief_content, encoding="utf-8") _json_dump( attempt_path, { "repairAttempt": attempt.to_dict(), "sourceSummary": result.summary, "sourceBlockers": result.blockers, "sourceValidations": [item.to_dict() for item in result.validations], "sourceEvidencePaths": result.evidence_paths, }, ) return attempt def list_repair_attempts(project_root: Path, task_id: str = "") -> List[RepairAttempt]: repair_root = _repair_root(project_root) if not repair_root.exists(): return [] attempts: List[RepairAttempt] = [] search_root = repair_root / task_id if task_id else repair_root if not search_root.exists(): return [] for path in sorted(search_root.rglob("attempt.json")): try: payload = json.loads(path.read_text(encoding="utf-8-sig")) attempt_payload = payload.get("repairAttempt", payload) attempt = RepairAttempt.from_dict(attempt_payload) attempt.validate() except (json.JSONDecodeError, TypeError, ValueError): continue if task_id and attempt.task_id != task_id: continue attempts.append(attempt) attempts.sort(key=lambda item: (item.created_at, item.repair_id)) return attempts def load_active_repair_attempt(project_root: Path, task_id: str) -> RepairAttempt | None: for attempt in reversed(list_repair_attempts(project_root, task_id)): if attempt.status in {"queued", "active"}: return attempt return None def _set_attempt_status(attempt: RepairAttempt, status: str) -> RepairAttempt: attempt.status = status attempt_path = Path(attempt.state_path) if attempt_path.exists(): payload = json.loads(attempt_path.read_text(encoding="utf-8-sig")) payload["repairAttempt"] = attempt.to_dict() _json_dump(attempt_path, payload) return attempt def ensure_repair_attempts_for_result( project_root: Path, result: WorkerResult, source_result_path: str = "" ) -> List[RepairAttempt]: policy = load_repair_policy(project_root) if not bool(policy.get("enabled", True)): return result.repair_attempts if result.repair_attempts: return result.repair_attempts existing_attempts = list_repair_attempts(project_root, result.task_id) active_attempts = [ item for item in existing_attempts if item.status in {"queued", "active"} ] if result.status == "done": if active_attempts: resolved_attempts = [] for attempt in active_attempts: resolved_attempts.append(_set_attempt_status(attempt, "resolved")) result.repair_attempts = resolved_attempts return result.repair_attempts reasons = _repair_reasons(result, policy) if not reasons: return result.repair_attempts if active_attempts: result.repair_attempts = active_attempts return result.repair_attempts if len(existing_attempts) >= int(policy.get("maxAttemptsPerTask", 2) or 2): result.notes.append( f"Automatic repair budget exhausted for {result.task_id}; user decision may be required." ) return result.repair_attempts attempt_index = len(existing_attempts) + 1 repair_id = f"{result.task_id}-repair-{attempt_index:03d}" created_at = now_iso() attempt = RepairAttempt( repair_id=repair_id, task_id=result.task_id, attempt_index=attempt_index, source_status=result.status, source_result_path=source_result_path or "", reason="; ".join(reasons), repair_brief_path=str(_repair_brief_path(project_root, result.task_id, repair_id)), state_path=str(_repair_attempt_path(project_root, result.task_id, repair_id)), worker_brief_path=str(_worker_repair_brief_path(project_root, result.task_id)), debug_session_ids=_ordered_unique( item.session_id for item in result.debug_sessions if item.session_id ), status="queued", created_at=created_at, ) _write_repair_attempt_artifacts(project_root, result, attempt) result.repair_attempts = [attempt] result.notes.append( f"Automatic repair attempt queued for {result.task_id}: {attempt.repair_id}" ) return result.repair_attempts def mark_repair_attempts_active(project_root: Path, task_id: str) -> List[RepairAttempt]: updated: List[RepairAttempt] = [] for attempt in list_repair_attempts(project_root, task_id): if attempt.status == "queued": updated.append(_set_attempt_status(attempt, "active")) return updated def merge_repair_attempts_into_state( state: Dict[str, object], repair_attempts: List[RepairAttempt] ) -> Dict[str, object]: state["repairPolicy"] = normalize_repair_policy(dict(state.get("repairPolicy", {}))) existing_items = list(state.get("repairAttempts", [])) items_by_id = {} for item in existing_items: if not isinstance(item, dict): continue repair_id = str(item.get("repairId", "")).strip() if repair_id: items_by_id[repair_id] = item for attempt in repair_attempts: items_by_id[attempt.repair_id] = attempt.to_dict() merged_items = [items_by_id[key] for key in sorted(items_by_id)] state["repairAttempts"] = merged_items state["activeRepairCount"] = sum( 1 for item in merged_items if str(item.get("status", "")).strip() in {"queued", "active"} ) if repair_attempts: latest = repair_attempts[-1] state["lastRepairAttemptId"] = latest.repair_id state["lastRepairTaskId"] = latest.task_id state["lastRepairAt"] = latest.created_at return state def summarize_active_repairs(state: Dict[str, object]) -> List[Dict[str, str]]: summary: List[Dict[str, str]] = [] for item in list(state.get("repairAttempts", [])): if not isinstance(item, dict): continue status = str(item.get("status", "")).strip() if status not in {"queued", "active"}: continue summary.append( { "repairId": str(item.get("repairId", "")).strip(), "taskId": str(item.get("taskId", "")).strip(), "status": status, "reason": str(item.get("reason", "")).strip(), "repairBriefPath": str(item.get("repairBriefPath", "")).strip(), } ) return summary def write_repair_queue(project_root: Path, state: Dict[str, object]) -> Path: queue_path = _repair_queue_path(project_root) active_items = summarize_active_repairs(state) lines = [ "# Air Engine Auto Repair Queue", "", "This file lists repair attempts that were automatically queued from blocked or failed worker results.", "", ] if active_items: for item in active_items: lines.append(f"- Repair: `{item['repairId']}`") lines.append(f" Task: `{item['taskId']}`") lines.append(f" Status: `{item['status']}`") lines.append(f" Reason: {item['reason'] or 'n/a'}") lines.append(f" Brief: `{item['repairBriefPath']}`") else: lines.append("- No active auto repair attempts.") queue_path.write_text("\n".join(lines) + "\n", encoding="utf-8") return queue_path