#!/usr/bin/env python3 """One card, two modes. The QA cycle between dry-run and a live cockpit. python -m cli.qa_cycle dry # propose and park; the shell does not run python -m cli.qa_cycle live # run the plan in .sandbox/, critic passes to done The provider is forced to mock so this never calls OMP, Hermes, or OpenClaw. The card is deleted before exit, including on failure. Refuses to start when a conductor or reaper is already running, so a QA card cannot race the live board. """ from __future__ import annotations import asyncio import json import os import subprocess import sys MODE = sys.argv[1] if len(sys.argv) > 1 else "" if MODE not in ("dry", "live"): print("usage: python -m cli.qa_cycle dry|live", file=sys.stderr) raise SystemExit(2) # Set before any KanBot import. workers reads DRY_RUN at import, and the repo # .env must not override this process (setdefault only fills what is missing). os.environ["KANBOT_DRY_RUN"] = "1" if MODE == "dry" else "0" os.environ["KANBOT_PROVIDER"] = "mock" os.environ["KANBOT_SWARM_N"] = "1" def _foreign_workers() -> list[str]: found = subprocess.run( ["pgrep", "-fl", "core.conductor|core.reaper"], capture_output=True, text=True, ) if found.returncode != 0: return [] return [line for line in found.stdout.splitlines() if "qa_cycle" not in line] async def _cycle() -> dict: from core import db from core.mcp_server import create_card, move_card from core.workers import DRY_RUN, claim_and_execute, run_critic if MODE == "dry" and not DRY_RUN: raise RuntimeError("dry cycle imported workers with DRY_RUN off") if MODE == "live" and DRY_RUN: raise RuntimeError("live cycle imported workers with DRY_RUN on") created = json.loads(await create_card( title="QA e2e sandbox ping", spec="Print a marker through the sandbox and post it as the artifact.", role="executor", acceptance_criteria=["the sandbox ran"], tags=["qa"], )) cid = created["id"] try: promoted = json.loads(await move_card( cid, "ready", actor="qa", reason="qa promote")) if promoted.get("rejected") or promoted.get("error"): raise RuntimeError(f"promote failed: {promoted}") card = await db.get_card(cid) await claim_and_execute(card) card = await db.get_card(cid) kinds = [entry.kind for entry in card.thread] if MODE == "dry": if card.column.value != "blocked": raise RuntimeError(f"dry-run left the card in {card.column.value}") if "tool_call" not in kinds or "tool_result" in kinds: raise RuntimeError(f"dry-run thread kinds: {kinds}") else: if card.column.value != "review": raise RuntimeError(f"live execution left the card in {card.column.value}") if "tool_result" not in kinds or "artifact" not in kinds: raise RuntimeError(f"live thread kinds: {kinds}") blob = "\n".join(entry.content for entry in card.thread) if "mock" not in blob: raise RuntimeError("sandbox stdout did not land on the thread") await run_critic(card) card = await db.get_card(cid) if card.column.value != "done": raise RuntimeError(f"critic left the card in {card.column.value}") return {"ok": True, "mode": MODE, "id": cid, "column": card.column.value} finally: pool = await db.pool() async with pool.acquire() as conn: await conn.execute("DELETE FROM cards WHERE id=$1", cid) await pool.close() def main() -> int: busy = _foreign_workers() if busy: print(json.dumps({"ok": False, "error": "conductor or reaper is running", "procs": busy})) return 3 try: report = asyncio.run(_cycle()) except Exception as exc: # noqa: BLE001 — one JSON line for the QA runner print(json.dumps({"ok": False, "mode": MODE, "error": f"{type(exc).__name__}: {exc}"})) return 1 print(json.dumps(report)) return 0 if __name__ == "__main__": raise SystemExit(main())