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