chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
373
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py
Executable file
373
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/repair_runtime.py
Executable file
@@ -0,0 +1,373 @@
|
||||
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
|
||||
Reference in New Issue
Block a user