Files
AirCoding/AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py
AirCoding ae44be31d5 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>
2026-06-12 17:12:29 +08:00

414 lines
14 KiB
Python
Executable File

from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Dict, Iterable, List, Tuple
from .contracts import DEFAULT_DEBUG_POLICY, DebugSession, WorkerResult, now_iso
from .paths import airdbg_root, agents_path, aireng_root, architecture_adr_dir, architecture_c4_module_path, debug_log_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_debug_policy(policy: Dict[str, object] | None = None) -> Dict[str, object]:
merged = dict(DEFAULT_DEBUG_POLICY)
if policy:
merged.update(policy)
merged["enabled"] = bool(merged.get("enabled", True))
merged["triggerOnBlocked"] = bool(merged.get("triggerOnBlocked", True))
merged["preferOriginalAirDbg"] = bool(merged.get("preferOriginalAirDbg", True))
merged["triggerValidationStatuses"] = _ordered_unique(
str(item).lower() for item in list(merged.get("triggerValidationStatuses", []))
)
try:
max_sessions = int(merged.get("maxSessionsPerTask", 1) or 1)
except (TypeError, ValueError):
max_sessions = 1
merged["maxSessionsPerTask"] = max(1, max_sessions)
return merged
def load_debug_policy(project_root: Path) -> Dict[str, object]:
state_path = aireng_root(project_root) / "state.json"
state = _json_load(state_path)
return normalize_debug_policy(state.get("debugPolicy", {}))
def merge_debug_sessions_into_state(
state: Dict[str, object], debug_sessions: List[DebugSession]
) -> Dict[str, object]:
state["debugPolicy"] = normalize_debug_policy(dict(state.get("debugPolicy", {})))
existing_items = list(state.get("debugSessions", []))
seen_ids = {
str(item.get("sessionId", "")).strip()
for item in existing_items
if isinstance(item, dict)
}
for session in debug_sessions:
if session.session_id in seen_ids:
continue
existing_items.append(session.to_dict())
seen_ids.add(session.session_id)
task_counts: Dict[str, int] = {}
for item in existing_items:
task_id = str(item.get("taskId", "")).strip()
if not task_id:
continue
task_counts[task_id] = task_counts.get(task_id, 0) + 1
state["debugSessions"] = existing_items
state["taskDebugCounts"] = task_counts
if debug_sessions:
latest = debug_sessions[-1]
state["lastDebugSessionId"] = latest.session_id
state["lastDebugTaskId"] = latest.task_id
state["lastDebugAt"] = latest.created_at
return state
def _debug_paths(project_root: Path) -> Dict[str, Path]:
root = airdbg_root(project_root)
return {
"root": root,
"state": root / "state.json",
"requests": root / "requests",
"sessions": root / "sessions",
"debug_log": debug_log_path(project_root),
}
def _airdbg_health(project_root: Path) -> Dict[str, bool]:
paths = _debug_paths(project_root)
return {
"AirPlan/AGENTS.md": agents_path(project_root).exists(),
"AirPlan/docs/architecture/c4/module.md": architecture_c4_module_path(project_root).exists(),
"AirPlan/docs/architecture/adr": architecture_adr_dir(project_root).exists(),
"debug_log": paths["debug_log"].exists(),
}
def _ensure_debug_layout(project_root: Path) -> Dict[str, Path]:
paths = _debug_paths(project_root)
paths["requests"].mkdir(parents=True, exist_ok=True)
paths["sessions"].mkdir(parents=True, exist_ok=True)
paths["debug_log"].parent.mkdir(parents=True, exist_ok=True)
if not paths["debug_log"].exists():
paths["debug_log"].write_text("# Debug Log\n\n", encoding="utf-8")
return paths
def _resolve_original_airdbg_script() -> Path | None:
candidates = [
Path.home() / "plugins" / "airdbg" / "scripts" / "airdbg_mode.py",
Path(r"C:\Users\20392\plugins\airdbg\scripts\airdbg_mode.py"),
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _bootstrap_airdbg(project_root: Path, prefer_original: bool) -> Tuple[str, str, str]:
mode = "runtime-bootstrap"
stdout = ""
error = ""
script_path = _resolve_original_airdbg_script() if prefer_original else None
if script_path is not None:
try:
completed = subprocess.run(
[
sys.executable,
str(script_path),
"--mode",
"enter",
"--project",
str(project_root),
],
capture_output=True,
text=True,
check=True,
)
mode = "original-airdbg"
stdout = completed.stdout.strip()
except subprocess.CalledProcessError as exc:
mode = "runtime-fallback"
stdout = exc.stdout.strip()
error = exc.stderr.strip() or str(exc)
paths = _ensure_debug_layout(project_root)
state = _json_load(paths["state"])
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airdbg_health(project_root)
if stdout:
state["lastBootstrapStdout"] = stdout
if error:
state["lastBootstrapError"] = error
_json_dump(paths["state"], state)
return mode, stdout, error
def list_debug_sessions(project_root: Path, task_id: str = "") -> List[DebugSession]:
session_dir = _debug_paths(project_root)["sessions"]
if not session_dir.exists():
return []
sessions: List[DebugSession] = []
for path in sorted(session_dir.glob("*.json")):
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
session_payload = payload.get("debugSession", payload)
session = DebugSession.from_dict(session_payload)
session.validate()
except (json.JSONDecodeError, TypeError, ValueError):
continue
if task_id and session.task_id != task_id:
continue
sessions.append(session)
sessions.sort(key=lambda item: (item.created_at, item.session_id))
return sessions
def _result_trigger_summary(
result: WorkerResult, policy: Dict[str, object]
) -> Tuple[List[str], str]:
triggers: List[str] = []
reasons: List[str] = []
if result.status == "blocked" and bool(policy.get("triggerOnBlocked", True)):
triggers.append("blocked-status")
blocker_text = ", ".join(result.blockers) if result.blockers else "worker returned blocked"
reasons.append(f"worker result is blocked: {blocker_text}")
tracked_validation_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_validation_statuses
]
if failed_validations:
triggers.append("validation-failure")
reasons.append(
"validation failures: "
+ ", ".join(f"{item.kind}:{item.status}" for item in failed_validations)
)
return triggers, "; ".join(reasons)
def _session_stamp() -> str:
return now_iso().replace(":", "-").replace(".", "-").replace("+", "-")
def _append_debug_log(
debug_log_path: Path,
result: WorkerResult,
session: DebugSession,
bootstrap_mode: str,
) -> None:
section_lines = [
f"### {session.created_at}: auto-debug request for {result.task_id}",
"",
f"- Task: `{result.task_id}`",
f"- Source: `{session.source}`",
f"- Trigger: `{session.trigger}`",
f"- Symptom: {result.summary}",
"- Expected: Task validations should pass, or the task should complete without blockers.",
f"- Actual: worker status `{result.status}`; blockers={', '.join(result.blockers) or 'none'}",
"- Reproduction: finalize the worker result and let the runtime auto-route the failure into AirDbg.",
"- Root cause: pending AirDbg investigation.",
f"- Fix: pending AirDbg session `{session.session_id}`",
(
"- Validation: debug request stored at "
f"`{session.request_path}`; log appended automatically; bootstrap mode `{bootstrap_mode}`"
),
f"- Evidence: {', '.join(f'`{item}`' for item in result.evidence_paths) or '`none`'}",
"- ADR/C4 updates: none yet",
"- Residual risk: the underlying blocker is unresolved until the debug session is completed.",
"",
]
existing = debug_log_path.read_text(encoding="utf-8") if debug_log_path.exists() else "# Debug Log\n\n"
debug_log_path.write_text(existing.rstrip() + "\n\n" + "\n".join(section_lines), encoding="utf-8")
def _update_airdbg_state(
project_root: Path,
session: DebugSession,
bootstrap_mode: str,
bootstrap_stdout: str,
bootstrap_error: str,
) -> None:
paths = _ensure_debug_layout(project_root)
state = _json_load(paths["state"])
sessions = list(state.get("autoDebugSessions", []))
known_ids = {
str(item.get("sessionId", "")).strip()
for item in sessions
if isinstance(item, dict)
}
if session.session_id not in known_ids:
sessions.append(
{
"sessionId": session.session_id,
"taskId": session.task_id,
"source": session.source,
"trigger": session.trigger,
"status": session.status,
"createdAt": session.created_at,
"requestPath": session.request_path,
}
)
state["enabled"] = True
state["updatedAt"] = now_iso()
state["projectRoot"] = str(project_root)
state["artifactHealth"] = _airdbg_health(project_root)
state["autoDebugSessions"] = sessions
state["autoDebugSessionCount"] = len(sessions)
state["lastAutoDebugAt"] = session.created_at
state["lastAutoDebugSessionId"] = session.session_id
state["lastBootstrapMode"] = bootstrap_mode
if bootstrap_stdout:
state["lastBootstrapStdout"] = bootstrap_stdout
if bootstrap_error:
state["lastBootstrapError"] = bootstrap_error
_json_dump(paths["state"], state)
def _attach_session_to_result(result: WorkerResult, session: DebugSession, note: str) -> None:
if not any(item.session_id == session.session_id for item in result.debug_sessions):
result.debug_sessions.append(session)
for path in [session.request_path, session.debug_log_path, session.state_path]:
if path and path not in result.evidence_paths:
result.evidence_paths.append(path)
if note not in result.notes:
result.notes.append(note)
def ensure_debug_sessions_for_result(
project_root: Path, result: WorkerResult, source: str
) -> List[DebugSession]:
policy = load_debug_policy(project_root)
if not bool(policy.get("enabled", True)):
return result.debug_sessions
triggers, reason = _result_trigger_summary(result, policy)
if not triggers:
return result.debug_sessions
existing_sessions = list_debug_sessions(project_root, result.task_id)
if result.debug_sessions:
for session in result.debug_sessions:
_attach_session_to_result(
result,
session,
f"Debug session already linked for {result.task_id}: {session.session_id}",
)
return result.debug_sessions
if existing_sessions:
latest = existing_sessions[-1]
_attach_session_to_result(
result,
latest,
f"Reused existing debug session for {result.task_id}: {latest.session_id}",
)
return result.debug_sessions
session_limit = int(policy.get("maxSessionsPerTask", 1) or 1)
if len(existing_sessions) >= session_limit:
return result.debug_sessions
paths = _ensure_debug_layout(project_root)
bootstrap_mode, bootstrap_stdout, bootstrap_error = _bootstrap_airdbg(
project_root, bool(policy.get("preferOriginalAirDbg", True))
)
created_at = now_iso()
session_id = f"{result.task_id}-{_session_stamp()}"
request_path = paths["requests"] / f"{session_id}.json"
session_path = paths["sessions"] / f"{session_id}.json"
trigger = ",".join(triggers)
request_payload = {
"sessionId": session_id,
"taskId": result.task_id,
"source": source,
"trigger": trigger,
"reason": reason or result.summary,
"workerStatus": result.status,
"summary": result.summary,
"filesChanged": result.files_changed,
"validations": [item.to_dict() for item in result.validations],
"evidencePaths": result.evidence_paths,
"risks": result.risks,
"blockers": result.blockers,
"createdAt": created_at,
"bootstrapMode": bootstrap_mode,
"bootstrapStdout": bootstrap_stdout,
"bootstrapError": bootstrap_error,
}
_json_dump(request_path, request_payload)
session = DebugSession(
session_id=session_id,
task_id=result.task_id,
source=source,
trigger=trigger,
reason=reason or result.summary,
request_path=str(request_path),
debug_log_path=str(paths["debug_log"]),
state_path=str(paths["state"]),
mode=bootstrap_mode,
status="requested",
created_at=created_at,
)
_json_dump(
session_path,
{
"debugSession": session.to_dict(),
"request": request_payload,
},
)
_append_debug_log(paths["debug_log"], result, session, bootstrap_mode)
_update_airdbg_state(project_root, session, bootstrap_mode, bootstrap_stdout, bootstrap_error)
_attach_session_to_result(
result,
session,
f"Auto-requested debug session for {result.task_id}: {session.session_id}",
)
return result.debug_sessions