Files
Kill_LIFE/tools/mcp_runtime_status.py
T
kxkm 1c6c1952e8 fix+feat: restore CI/PIO regressions, 30-entry HF dataset, new tests & hardware
Regressions fixed (aa916de simplification):
- firmware/platformio.ini: restore esp32s3_waveshare (pioarduino platform,
  lib_deps: ArduinoJson/ESP32_Display_Panel/ESP32-audioI2S/IO_Expander,
  BOARD_HAS_PSRAM, I2S pins), restore esp32s3_qemu (extends waveshare +
  QEMU_BUILD), fix default_envs=esp32s3_waveshare, keep build_dir=/tmp/kl_pio_build
- .github/workflows/ci.yml: restore firmware-native (112 Unity tests),
  firmware-build (esp32s3_waveshare artifact), firmware-sim (Wokwi gated),
  hardware-export (KiCad ERC + SVG/PDF/netlist + KiBot + compliance)
- .gitignore: add .kibot-venv/ and .pio-venv/ (prevent committing venvs)

Dataset (HuggingFace clemsail/kill-life-embedded-qa v2):
- generate_hf_dataset.py: rag_query timeout 30s->120s (LLM takes ~45s),
  rag_search timeout 15s->30s; resolves intermittent server-busy failures
- 30 entries (10+10+5+5) -- 100% coverage vs 21/40 previously

Firmware tests (112/112):
- 4 suites: test_basic (39), test_modules (32), test_radio_state (26),
  test_wifi_state (15); test_logic.cpp now a stub (content moved to test_basic)

Hardware:
- esp32_minimal.kicad_pcb, esp32s3_enclosure.FCStd/.step, gen_pcb.py,
  gen_enclosure.py, REGISTRY.md, ERC reports for all design blocks

MCP tools:
- apify_mcp.py: +5 Kill_LIFE tools (fetch_espressif_docs, fetch_kicad_library_info,
  fetch_platformio_registry, ingest_to_rag, get_runtime_info)
- mcp_runtime_status.py: fix classify_overall -- accept_degraded respected for
  failed checks, task annotations, optional_degraded logic cleaned up
- deploy/cad/docker-compose.yml: path mascarade->mascarade-main

Specs & docs:
- docs/plans/TODO_2026-03-26.md, TODO_2026-03-27.md
- ai-agentic-embedded-base/specs: arch, tasks, intake, spec updates
- docs/playbooks/kicad_happy_hw_bom_forge.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:14:33 +01:00

272 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Aggregate local MCP runtime readiness across Kill_LIFE smoke helpers."""
from __future__ import annotations
import asyncio
import argparse
import json
import sys
from pathlib import Path
from typing import Any
try:
from .mcp_smoke_common import load_runtime_env
except ImportError: # pragma: no cover - script entrypoint fallback
from mcp_smoke_common import load_runtime_env
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CHECK_TIMEOUT_S = 120.0
DEFAULT_MAX_PARALLEL = 4
CHECKS: tuple[dict[str, Any], ...] = (
{
"name": "validate-specs",
"cmd": ["python3", "tools/validate_specs_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": False,
"timeout_s": 45.0,
},
{
"name": "kicad",
"cmd": ["python3", "tools/hw/mcp_smoke.py", "--json", "--quick", "--timeout", "30"],
"accept_degraded": True,
"task": "K-010",
"blocked_when": "kicad_mcp_server source empty (mascarade-main/finetune/kicad_mcp_server not populated)",
"timeout_s": 90.0,
},
{
"name": "kicad-host",
"cmd": ["python3", "tools/hw/kicad_host_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": True,
"optional_degraded": True,
"task": "K-012",
"blocked_when": "host_pcbnew_import != ok (optional host-native path)",
"timeout_s": 60.0,
},
{
"name": "knowledge-base",
"cmd": ["python3", "tools/knowledge_base_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": True,
"timeout_s": 60.0,
},
{
"name": "github-dispatch",
"cmd": ["python3", "tools/github_dispatch_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": True,
"timeout_s": 60.0,
},
{
"name": "freecad",
"cmd": ["python3", "tools/freecad_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": False,
"task": "F-101",
"timeout_s": 120.0,
},
{
"name": "openscad",
"cmd": ["python3", "tools/openscad_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": False,
"task": "O-101",
"timeout_s": 120.0,
},
{
"name": "nexar-api",
"cmd": ["python3", "tools/nexar_mcp_smoke.py", "--json"],
"accept_degraded": True,
"task": "K-014",
"blocked_when": "live Nexar unavailable (token missing, demo mode, or external account/quota limit)",
"optional_degraded_when_live_validation": ("quota_exceeded",),
"timeout_s": 120.0,
},
{
"name": "ngspice",
"cmd": ["python3", "tools/ngspice_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": False,
"timeout_s": 60.0,
},
{
"name": "platformio",
"cmd": ["python3", "tools/platformio_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": False,
"timeout_s": 90.0,
},
{
"name": "apify",
"cmd": ["python3", "tools/apify_mcp_smoke.py", "--json", "--quick"],
"accept_degraded": True,
"blocked_when": "APIFY_API_KEY not configured (direct-scrape fallback active)",
"timeout_s": 60.0,
},
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--json", action="store_true")
parser.add_argument("--strict", action="store_true", help="Treat degraded checks as failures.")
parser.add_argument(
"--max-parallel",
type=int,
default=DEFAULT_MAX_PARALLEL,
help=f"Maximum concurrent checks (default: {DEFAULT_MAX_PARALLEL}).",
)
return parser.parse_args()
def classify_overall(results: list[dict[str, Any]], *, strict: bool) -> str:
any_failed = False
any_degraded = False
for result in results:
status = result.get("status")
accept = result.get("accept_degraded", False)
optional = result.get("optional_degraded", False)
if status == "failed":
# In non-strict mode, accept_degraded lets a failed check count as degraded.
if strict or not accept:
any_failed = True
elif not optional:
any_degraded = True
continue
if status == "degraded" and (strict or not accept):
any_failed = True
continue
if status == "degraded" and optional:
continue
if status == "degraded":
any_degraded = True
if any_failed:
return "failed"
if any_degraded:
return "degraded"
return "ready"
def derive_blockers(results: list[dict[str, Any]]) -> list[dict[str, str]]:
blockers: list[dict[str, str]] = []
for result in results:
task = result.get("task")
if not task or result.get("status") != "degraded" or result.get("optional_degraded", False):
continue
name = result.get("name", "unknown")
error = str(result.get("error") or "blocked by environment")
blocked_when = str(result.get("blocked_when") or "")
blockers.append(
{
"task": task,
"check": name,
"reason": error,
"condition": blocked_when,
}
)
return blockers
async def run_check(spec: dict[str, Any]) -> dict[str, Any]:
timeout_s = float(spec.get("timeout_s") or DEFAULT_CHECK_TIMEOUT_S)
try:
proc = await asyncio.create_subprocess_exec(
*spec["cmd"],
cwd=str(ROOT),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except Exception as exc:
return {
"name": spec["name"],
"status": "failed",
"error": f"spawn failed: {exc}",
"accept_degraded": spec.get("accept_degraded", False),
"optional_degraded": spec.get("optional_degraded", False),
"exit_code": -1,
}
try:
raw_stdout, raw_stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except TimeoutError:
proc.kill()
await proc.communicate()
payload = {
"status": "failed",
"error": f"timeout after {timeout_s:.1f}s",
"stdout": "",
}
stderr = ""
else:
stdout = raw_stdout.decode("utf-8", errors="replace").strip()
stderr = raw_stderr.decode("utf-8", errors="replace").strip()
try:
payload = json.loads(stdout) if stdout else {}
except json.JSONDecodeError as exc:
payload = {
"status": "failed",
"error": f"invalid JSON: {exc}",
"stdout": stdout,
}
payload.setdefault("status", "failed")
payload.setdefault("error", None)
payload["name"] = spec["name"]
payload["accept_degraded"] = spec.get("accept_degraded", False)
payload["optional_degraded"] = spec.get("optional_degraded", False)
live_validation = payload.get("live_validation")
if (
payload.get("status") == "degraded"
and live_validation in spec.get("optional_degraded_when_live_validation", ())
):
payload["optional_degraded"] = True
if "task" in spec:
payload["task"] = spec["task"]
if "blocked_when" in spec:
payload["blocked_when"] = spec["blocked_when"]
payload["exit_code"] = proc.returncode
if stderr:
payload["stderr"] = stderr
return payload
async def run_all_checks(*, max_parallel: int) -> list[dict[str, Any]]:
limit = max(1, int(max_parallel))
semaphore = asyncio.Semaphore(limit)
async def _guarded(spec: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
return await run_check(spec)
return list(await asyncio.gather(*(_guarded(spec) for spec in CHECKS)))
def emit(payload: dict[str, Any], *, json_output: bool) -> int:
if json_output:
print(json.dumps(payload, ensure_ascii=True))
else:
print(f"MCP runtime status: {payload['status']}")
print(f"Checks: {payload['counts']['ready']} ready / {payload['counts']['degraded']} degraded / {payload['counts']['failed']} failed")
if payload["blockers"]:
print("Open blocked tasks:")
for blocker in payload["blockers"]:
print(f"- {blocker['task']} via {blocker['check']}: {blocker['reason']}")
return 0 if payload["status"] == "ready" else 1
def main() -> int:
args = parse_args()
load_runtime_env()
results = asyncio.run(run_all_checks(max_parallel=args.max_parallel))
payload = {
"status": classify_overall(results, strict=args.strict),
"strict": args.strict,
"max_parallel": max(1, int(args.max_parallel)),
"checks": results,
"counts": {
"ready": sum(1 for result in results if result.get("status") == "ready"),
"degraded": sum(1 for result in results if result.get("status") == "degraded"),
"failed": sum(1 for result in results if result.get("status") == "failed"),
},
"blockers": derive_blockers(results),
}
return emit(payload, json_output=args.json)
if __name__ == "__main__":
raise SystemExit(main())