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:
413
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py
Executable file
413
AirPlan/docs/spec/AirPlan-ParaV2/lib/air_runtime/debug_runtime.py
Executable file
@@ -0,0 +1,413 @@
|
||||
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
|
||||
Reference in New Issue
Block a user